From 14f187e69b9237954814fac9a2fa1b912c9aa475 Mon Sep 17 00:00:00 2001 From: pooya parsa Date: Thu, 2 Jul 2020 15:02:35 +0200 Subject: [PATCH] initial commit --- packages/nuxt3/src/babel-preset-app/index.js | 175 ++++ .../src/babel-preset-app/polyfills-plugin.js | 24 + packages/nuxt3/src/builder/builder.ts | 849 ++++++++++++++++++ packages/nuxt3/src/builder/context/build.ts | 16 + .../nuxt3/src/builder/context/template.ts | 70 ++ packages/nuxt3/src/builder/ignore.ts | 59 ++ packages/nuxt3/src/builder/index.ts | 10 + packages/nuxt3/src/cli/command.ts | 237 +++++ packages/nuxt3/src/cli/commands/build.ts | 92 ++ packages/nuxt3/src/cli/commands/dev.ts | 116 +++ packages/nuxt3/src/cli/commands/export.ts | 50 ++ packages/nuxt3/src/cli/commands/generate.ts | 110 +++ packages/nuxt3/src/cli/commands/help.ts | 29 + packages/nuxt3/src/cli/commands/index.ts | 17 + packages/nuxt3/src/cli/commands/serve.ts | 83 ++ packages/nuxt3/src/cli/commands/start.ts | 24 + packages/nuxt3/src/cli/commands/webpack.ts | 114 +++ packages/nuxt3/src/cli/index.ts | 13 + packages/nuxt3/src/cli/list.ts | 35 + packages/nuxt3/src/cli/options/common.ts | 68 ++ packages/nuxt3/src/cli/options/index.ts | 9 + packages/nuxt3/src/cli/options/locking.ts | 7 + packages/nuxt3/src/cli/options/server.ts | 29 + packages/nuxt3/src/cli/run.ts | 60 ++ packages/nuxt3/src/cli/setup.ts | 38 + packages/nuxt3/src/cli/utils/banner.ts | 60 ++ packages/nuxt3/src/cli/utils/config.ts | 33 + packages/nuxt3/src/cli/utils/constants.ts | 8 + packages/nuxt3/src/cli/utils/formatting.ts | 69 ++ packages/nuxt3/src/cli/utils/index.ts | 64 ++ packages/nuxt3/src/cli/utils/memory.ts | 18 + packages/nuxt3/src/cli/utils/webpack.ts | 9 + packages/nuxt3/src/config/config/_app.ts | 76 ++ packages/nuxt3/src/config/config/_common.ts | 92 ++ packages/nuxt3/src/config/config/build.ts | 130 +++ packages/nuxt3/src/config/config/cli.ts | 4 + packages/nuxt3/src/config/config/generate.ts | 17 + packages/nuxt3/src/config/config/index.ts | 33 + packages/nuxt3/src/config/config/messages.ts | 12 + packages/nuxt3/src/config/config/modes.ts | 20 + packages/nuxt3/src/config/config/render.ts | 45 + packages/nuxt3/src/config/config/router.ts | 18 + packages/nuxt3/src/config/config/server.ts | 14 + packages/nuxt3/src/config/index.ts | 3 + packages/nuxt3/src/config/load.ts | 187 ++++ packages/nuxt3/src/config/options.ts | 494 ++++++++++ packages/nuxt3/src/core/index.ts | 5 + packages/nuxt3/src/core/load.ts | 40 + packages/nuxt3/src/core/module.ts | 215 +++++ packages/nuxt3/src/core/nuxt.ts | 115 +++ packages/nuxt3/src/core/resolver.ts | 182 ++++ packages/nuxt3/src/generator/generator.ts | 412 +++++++++ packages/nuxt3/src/generator/index.ts | 6 + packages/nuxt3/src/server/context.ts | 8 + packages/nuxt3/src/server/index.ts | 2 + packages/nuxt3/src/server/jsdom.ts | 72 ++ packages/nuxt3/src/server/listener.ts | 119 +++ packages/nuxt3/src/server/middleware/error.ts | 130 +++ packages/nuxt3/src/server/middleware/nuxt.ts | 155 ++++ .../nuxt3/src/server/middleware/timing.ts | 56 ++ packages/nuxt3/src/server/package.ts | 6 + packages/nuxt3/src/server/server.ts | 388 ++++++++ packages/nuxt3/src/utils/cjs.ts | 67 ++ packages/nuxt3/src/utils/constants.ts | 9 + packages/nuxt3/src/utils/context.ts | 21 + packages/nuxt3/src/utils/index.ts | 11 + packages/nuxt3/src/utils/lang.ts | 44 + packages/nuxt3/src/utils/locking.ts | 102 +++ packages/nuxt3/src/utils/modern.ts | 64 ++ packages/nuxt3/src/utils/resolve.ts | 109 +++ packages/nuxt3/src/utils/route.ts | 252 ++++++ packages/nuxt3/src/utils/serialize.ts | 52 ++ packages/nuxt3/src/utils/task.ts | 36 + packages/nuxt3/src/utils/timer.ts | 65 ++ packages/nuxt3/src/vue-app/app.pages.vue | 46 + packages/nuxt3/src/vue-app/app.tutorial.vue | 0 .../nuxt3/src/vue-app/components/index.js | 1 + packages/nuxt3/src/vue-app/index.js | 1 + packages/nuxt3/src/vue-app/nuxt.js | 28 + .../nuxt3/src/vue-app/nuxt/entry.client.js | 25 + .../nuxt3/src/vue-app/nuxt/entry.server.js | 19 + .../src/vue-app/nuxt/layouts/default.vue | 3 + .../nuxt3/src/vue-app/nuxt/plugins.client.js | 5 + packages/nuxt3/src/vue-app/nuxt/plugins.js | 11 + .../nuxt3/src/vue-app/nuxt/plugins.server.js | 7 + packages/nuxt3/src/vue-app/nuxt/routes.js | 19 + .../src/vue-app/nuxt/views/app.template.html | 9 + .../nuxt3/src/vue-app/nuxt/views/error.html | 23 + .../nuxt3/src/vue-app/plugins/components.js | 11 + packages/nuxt3/src/vue-app/plugins/legacy.js | 15 + packages/nuxt3/src/vue-app/plugins/preload.js | 9 + packages/nuxt3/src/vue-app/plugins/router.js | 37 + packages/nuxt3/src/vue-app/plugins/state.js | 13 + packages/nuxt3/src/vue-app/template.ts | 12 + packages/nuxt3/src/vue-app/utils.js | 3 + .../src/vue-app/vetur/nuxt-attributes.json | 38 + .../nuxt3/src/vue-app/vetur/nuxt-tags.json | 47 + packages/nuxt3/src/vue-renderer/index.ts | 1 + packages/nuxt3/src/vue-renderer/renderer.ts | 386 ++++++++ .../nuxt3/src/vue-renderer/renderers/base.ts | 19 + .../src/vue-renderer/renderers/modern.ts | 123 +++ .../nuxt3/src/vue-renderer/renderers/spa.ts | 204 +++++ .../nuxt3/src/vue-renderer/renderers/ssr.ts | 292 ++++++ packages/nuxt3/src/webpack/builder.ts | 256 ++++++ packages/nuxt3/src/webpack/config/base.ts | 515 +++++++++++ packages/nuxt3/src/webpack/config/client.ts | 235 +++++ packages/nuxt3/src/webpack/config/index.ts | 3 + packages/nuxt3/src/webpack/config/modern.ts | 15 + packages/nuxt3/src/webpack/config/server.ts | 161 ++++ packages/nuxt3/src/webpack/index.ts | 1 + .../nuxt3/src/webpack/plugins/externals.ts | 154 ++++ .../nuxt3/src/webpack/plugins/vue/client.ts | 111 +++ .../nuxt3/src/webpack/plugins/vue/cors.ts | 26 + .../nuxt3/src/webpack/plugins/vue/modern.ts | 130 +++ .../nuxt3/src/webpack/plugins/vue/server.ts | 71 ++ .../nuxt3/src/webpack/plugins/vue/util.ts | 32 + .../src/webpack/plugins/warning-ignore.ts | 11 + packages/nuxt3/src/webpack/utils/index.ts | 3 + packages/nuxt3/src/webpack/utils/mfs.ts | 21 + .../nuxt3/src/webpack/utils/perf-loader.ts | 53 ++ packages/nuxt3/src/webpack/utils/postcss.ts | 187 ++++ .../nuxt3/src/webpack/utils/reserved-tags.ts | 23 + .../nuxt3/src/webpack/utils/style-loader.ts | 136 +++ 123 files changed, 10034 insertions(+) create mode 100644 packages/nuxt3/src/babel-preset-app/index.js create mode 100644 packages/nuxt3/src/babel-preset-app/polyfills-plugin.js create mode 100644 packages/nuxt3/src/builder/builder.ts create mode 100644 packages/nuxt3/src/builder/context/build.ts create mode 100644 packages/nuxt3/src/builder/context/template.ts create mode 100644 packages/nuxt3/src/builder/ignore.ts create mode 100644 packages/nuxt3/src/builder/index.ts create mode 100644 packages/nuxt3/src/cli/command.ts create mode 100644 packages/nuxt3/src/cli/commands/build.ts create mode 100644 packages/nuxt3/src/cli/commands/dev.ts create mode 100644 packages/nuxt3/src/cli/commands/export.ts create mode 100644 packages/nuxt3/src/cli/commands/generate.ts create mode 100644 packages/nuxt3/src/cli/commands/help.ts create mode 100644 packages/nuxt3/src/cli/commands/index.ts create mode 100644 packages/nuxt3/src/cli/commands/serve.ts create mode 100644 packages/nuxt3/src/cli/commands/start.ts create mode 100644 packages/nuxt3/src/cli/commands/webpack.ts create mode 100644 packages/nuxt3/src/cli/index.ts create mode 100644 packages/nuxt3/src/cli/list.ts create mode 100644 packages/nuxt3/src/cli/options/common.ts create mode 100644 packages/nuxt3/src/cli/options/index.ts create mode 100644 packages/nuxt3/src/cli/options/locking.ts create mode 100644 packages/nuxt3/src/cli/options/server.ts create mode 100644 packages/nuxt3/src/cli/run.ts create mode 100644 packages/nuxt3/src/cli/setup.ts create mode 100644 packages/nuxt3/src/cli/utils/banner.ts create mode 100644 packages/nuxt3/src/cli/utils/config.ts create mode 100644 packages/nuxt3/src/cli/utils/constants.ts create mode 100644 packages/nuxt3/src/cli/utils/formatting.ts create mode 100644 packages/nuxt3/src/cli/utils/index.ts create mode 100644 packages/nuxt3/src/cli/utils/memory.ts create mode 100644 packages/nuxt3/src/cli/utils/webpack.ts create mode 100644 packages/nuxt3/src/config/config/_app.ts create mode 100644 packages/nuxt3/src/config/config/_common.ts create mode 100644 packages/nuxt3/src/config/config/build.ts create mode 100644 packages/nuxt3/src/config/config/cli.ts create mode 100644 packages/nuxt3/src/config/config/generate.ts create mode 100644 packages/nuxt3/src/config/config/index.ts create mode 100644 packages/nuxt3/src/config/config/messages.ts create mode 100644 packages/nuxt3/src/config/config/modes.ts create mode 100644 packages/nuxt3/src/config/config/render.ts create mode 100644 packages/nuxt3/src/config/config/router.ts create mode 100644 packages/nuxt3/src/config/config/server.ts create mode 100644 packages/nuxt3/src/config/index.ts create mode 100644 packages/nuxt3/src/config/load.ts create mode 100644 packages/nuxt3/src/config/options.ts create mode 100644 packages/nuxt3/src/core/index.ts create mode 100644 packages/nuxt3/src/core/load.ts create mode 100644 packages/nuxt3/src/core/module.ts create mode 100644 packages/nuxt3/src/core/nuxt.ts create mode 100644 packages/nuxt3/src/core/resolver.ts create mode 100644 packages/nuxt3/src/generator/generator.ts create mode 100644 packages/nuxt3/src/generator/index.ts create mode 100644 packages/nuxt3/src/server/context.ts create mode 100644 packages/nuxt3/src/server/index.ts create mode 100644 packages/nuxt3/src/server/jsdom.ts create mode 100644 packages/nuxt3/src/server/listener.ts create mode 100644 packages/nuxt3/src/server/middleware/error.ts create mode 100644 packages/nuxt3/src/server/middleware/nuxt.ts create mode 100644 packages/nuxt3/src/server/middleware/timing.ts create mode 100644 packages/nuxt3/src/server/package.ts create mode 100644 packages/nuxt3/src/server/server.ts create mode 100644 packages/nuxt3/src/utils/cjs.ts create mode 100644 packages/nuxt3/src/utils/constants.ts create mode 100644 packages/nuxt3/src/utils/context.ts create mode 100644 packages/nuxt3/src/utils/index.ts create mode 100644 packages/nuxt3/src/utils/lang.ts create mode 100644 packages/nuxt3/src/utils/locking.ts create mode 100644 packages/nuxt3/src/utils/modern.ts create mode 100644 packages/nuxt3/src/utils/resolve.ts create mode 100644 packages/nuxt3/src/utils/route.ts create mode 100644 packages/nuxt3/src/utils/serialize.ts create mode 100644 packages/nuxt3/src/utils/task.ts create mode 100644 packages/nuxt3/src/utils/timer.ts create mode 100644 packages/nuxt3/src/vue-app/app.pages.vue create mode 100644 packages/nuxt3/src/vue-app/app.tutorial.vue create mode 100644 packages/nuxt3/src/vue-app/components/index.js create mode 100644 packages/nuxt3/src/vue-app/index.js create mode 100644 packages/nuxt3/src/vue-app/nuxt.js create mode 100644 packages/nuxt3/src/vue-app/nuxt/entry.client.js create mode 100644 packages/nuxt3/src/vue-app/nuxt/entry.server.js create mode 100644 packages/nuxt3/src/vue-app/nuxt/layouts/default.vue create mode 100644 packages/nuxt3/src/vue-app/nuxt/plugins.client.js create mode 100644 packages/nuxt3/src/vue-app/nuxt/plugins.js create mode 100644 packages/nuxt3/src/vue-app/nuxt/plugins.server.js create mode 100644 packages/nuxt3/src/vue-app/nuxt/routes.js create mode 100644 packages/nuxt3/src/vue-app/nuxt/views/app.template.html create mode 100644 packages/nuxt3/src/vue-app/nuxt/views/error.html create mode 100644 packages/nuxt3/src/vue-app/plugins/components.js create mode 100644 packages/nuxt3/src/vue-app/plugins/legacy.js create mode 100644 packages/nuxt3/src/vue-app/plugins/preload.js create mode 100644 packages/nuxt3/src/vue-app/plugins/router.js create mode 100644 packages/nuxt3/src/vue-app/plugins/state.js create mode 100644 packages/nuxt3/src/vue-app/template.ts create mode 100644 packages/nuxt3/src/vue-app/utils.js create mode 100644 packages/nuxt3/src/vue-app/vetur/nuxt-attributes.json create mode 100644 packages/nuxt3/src/vue-app/vetur/nuxt-tags.json create mode 100644 packages/nuxt3/src/vue-renderer/index.ts create mode 100644 packages/nuxt3/src/vue-renderer/renderer.ts create mode 100644 packages/nuxt3/src/vue-renderer/renderers/base.ts create mode 100644 packages/nuxt3/src/vue-renderer/renderers/modern.ts create mode 100644 packages/nuxt3/src/vue-renderer/renderers/spa.ts create mode 100644 packages/nuxt3/src/vue-renderer/renderers/ssr.ts create mode 100644 packages/nuxt3/src/webpack/builder.ts create mode 100644 packages/nuxt3/src/webpack/config/base.ts create mode 100644 packages/nuxt3/src/webpack/config/client.ts create mode 100644 packages/nuxt3/src/webpack/config/index.ts create mode 100644 packages/nuxt3/src/webpack/config/modern.ts create mode 100644 packages/nuxt3/src/webpack/config/server.ts create mode 100644 packages/nuxt3/src/webpack/index.ts create mode 100644 packages/nuxt3/src/webpack/plugins/externals.ts create mode 100644 packages/nuxt3/src/webpack/plugins/vue/client.ts create mode 100644 packages/nuxt3/src/webpack/plugins/vue/cors.ts create mode 100644 packages/nuxt3/src/webpack/plugins/vue/modern.ts create mode 100644 packages/nuxt3/src/webpack/plugins/vue/server.ts create mode 100644 packages/nuxt3/src/webpack/plugins/vue/util.ts create mode 100644 packages/nuxt3/src/webpack/plugins/warning-ignore.ts create mode 100644 packages/nuxt3/src/webpack/utils/index.ts create mode 100644 packages/nuxt3/src/webpack/utils/mfs.ts create mode 100644 packages/nuxt3/src/webpack/utils/perf-loader.ts create mode 100644 packages/nuxt3/src/webpack/utils/postcss.ts create mode 100644 packages/nuxt3/src/webpack/utils/reserved-tags.ts create mode 100644 packages/nuxt3/src/webpack/utils/style-loader.ts diff --git a/packages/nuxt3/src/babel-preset-app/index.js b/packages/nuxt3/src/babel-preset-app/index.js new file mode 100644 index 0000000000..7735d9ad4b --- /dev/null +++ b/packages/nuxt3/src/babel-preset-app/index.js @@ -0,0 +1,175 @@ +const coreJsMeta = { + 2: { + prefixes: { + es6: 'es6', + es7: 'es7' + }, + builtIns: '@babel/compat-data/corejs2-built-ins' + }, + 3: { + prefixes: { + es6: 'es', + es7: 'es' + }, + builtIns: 'core-js-compat/data' + } +} + +function getDefaultPolyfills (corejs) { + const { prefixes: { es6, es7 } } = coreJsMeta[corejs.version] + return [ + // Promise polyfill alone doesn't work in IE, + // Needs this as well. see: #1642 + `${es6}.array.iterator`, + // This is required for webpack code splitting, vuex etc. + `${es6}.promise`, + // this is needed for object rest spread support in templates + // as vue-template-es2015-compiler 1.8+ compiles it to Object.assign() calls. + `${es6}.object.assign`, + // #2012 es7.promise replaces native Promise in FF and causes missing finally + `${es7}.promise.finally` + ] +} + +function getPolyfills (targets, includes, { ignoreBrowserslistConfig, configPath, corejs }) { + const { default: getTargets, isRequired } = require('@babel/helper-compilation-targets') + const builtInsList = require(coreJsMeta[corejs.version].builtIns) + const builtInTargets = getTargets(targets, { + ignoreBrowserslistConfig, + configPath + }) + + return includes.filter(item => isRequired( + 'nuxt-polyfills', + builtInTargets, + { + compatData: { 'nuxt-polyfills': builtInsList[item] } + } + )) +} + +function isPackageHoisted (packageName) { + const path = require('path') + const installedPath = require.resolve(packageName) + const relativePath = path.resolve(__dirname, '..', 'node_modules', packageName) + return installedPath !== relativePath +} + +module.exports = (api, options = {}) => { + const presets = [] + const plugins = [] + + const envName = api.env() + + const { + bugfixes, + polyfills: userPolyfills, + loose = false, + debug = false, + useBuiltIns = 'usage', + modules = false, + spec, + ignoreBrowserslistConfig = envName === 'modern', + configPath, + include, + exclude, + shippedProposals, + forceAllTransforms, + decoratorsBeforeExport, + decoratorsLegacy, + absoluteRuntime + } = options + + let { corejs = { version: 3 } } = options + + if (typeof corejs !== 'object') { + corejs = { version: Number(corejs) } + } + + const isCorejs3Hoisted = isPackageHoisted('core-js') + if ( + (corejs.version === 3 && !isCorejs3Hoisted) || + (corejs.version === 2 && isCorejs3Hoisted) + ) { + // eslint-disable-next-line no-console + (console.fatal || console.error)(`babel corejs option is ${corejs.version}, please directlly install core-js@${corejs.version}.`) + } + + const defaultTargets = { + server: { node: 'current' }, + client: { ie: 9 }, + modern: { esmodules: true } + } + + let { targets = defaultTargets[envName] } = options + + // modern mode can only be { esmodules: true } + if (envName === 'modern') { + targets = defaultTargets.modern + } + + const polyfills = [] + + if (envName === 'client' && useBuiltIns === 'usage') { + polyfills.push( + ...getPolyfills( + targets, + userPolyfills || getDefaultPolyfills(corejs), + { + ignoreBrowserslistConfig, + configPath, + corejs + } + ) + ) + plugins.push([require('./polyfills-plugin'), { polyfills }]) + } + + // Pass options along to babel-preset-env + presets.push([ + require('@babel/preset-env'), { + bugfixes, + spec, + loose, + debug, + modules, + targets, + useBuiltIns, + corejs, + ignoreBrowserslistConfig, + configPath, + include, + exclude: polyfills.concat(exclude || []), + shippedProposals, + forceAllTransforms + } + ]) + + // JSX + if (options.jsx !== false) { + // presets.push([require('@vue/babel-preset-jsx'), Object.assign({}, options.jsx)]) + } + + plugins.push( + [require('@babel/plugin-proposal-decorators'), { + decoratorsBeforeExport, + legacy: decoratorsLegacy !== false + }], + [require('@babel/plugin-proposal-class-properties'), { loose: true }] + ) + + // Transform runtime, but only for helpers + plugins.push([require('@babel/plugin-transform-runtime'), { + regenerator: useBuiltIns !== 'usage', + corejs: false, + helpers: useBuiltIns === 'usage', + useESModules: envName !== 'server', + absoluteRuntime + }]) + + return { + sourceType: 'unambiguous', + presets, + plugins + } +} diff --git a/packages/nuxt3/src/babel-preset-app/polyfills-plugin.js b/packages/nuxt3/src/babel-preset-app/polyfills-plugin.js new file mode 100644 index 0000000000..2d0b32b476 --- /dev/null +++ b/packages/nuxt3/src/babel-preset-app/polyfills-plugin.js @@ -0,0 +1,24 @@ +// Add polyfill imports to the first file encountered. +module.exports = ({ types }) => { + let entryFile + return { + name: 'inject-polyfills', + visitor: { + Program (path, state) { + if (!entryFile) { + entryFile = state.filename + } else if (state.filename !== entryFile) { + return + } + + const { polyfills } = state.opts + const { createImport } = require('@babel/preset-env/lib/utils') + + // Imports are injected in reverse order + polyfills.slice().reverse().forEach((p) => { + createImport(path, p) + }) + } + } + } +} diff --git a/packages/nuxt3/src/builder/builder.ts b/packages/nuxt3/src/builder/builder.ts new file mode 100644 index 0000000000..d77409e41a --- /dev/null +++ b/packages/nuxt3/src/builder/builder.ts @@ -0,0 +1,849 @@ +import path from 'path' +import chalk from 'chalk' +import chokidar from 'chokidar' +import consola from 'consola' +import fsExtra from 'fs-extra' +import Glob from 'glob' +import hash from 'hash-sum' +import pify from 'pify' +import upath from 'upath' +import semver from 'semver' + +import debounce from 'lodash/debounce' +import omit from 'lodash/omit' +import template from 'lodash/template' +import uniq from 'lodash/uniq' +import uniqBy from 'lodash/uniqBy' + +import { BundleBuilder } from 'src/webpack' +import vueAppTemplate from 'src/vue-app/template' + +import { + r, + createRoutes, + relativeTo, + waitFor, + determineGlobals, + stripWhitespace, + isIndexFileAndFolder, + scanRequireTree, + TARGETS, + isFullStatic +} from 'src/utils' + +import Ignore from './ignore' +import BuildContext from './context/build' +import TemplateContext from './context/template' + +const glob = pify(Glob) +export default class Builder { + constructor (nuxt, bundleBuilder) { + this.nuxt = nuxt + this.plugins = [] + this.options = nuxt.options + this.globals = determineGlobals(nuxt.options.globalName, nuxt.options.globals) + this.watchers = { + files: null, + custom: null, + restart: null + } + + this.supportedExtensions = ['vue', 'js', ...(this.options.build.additionalExtensions || [])] + + // Helper to resolve build paths + this.relativeToBuild = (...args) => relativeTo(this.options.buildDir, ...args) + + this._buildStatus = STATUS.INITIAL + + // Hooks for watch lifecycle + if (this.options.dev) { + // Start watching after initial render + this.nuxt.hook('build:done', () => { + consola.info('Waiting for file changes') + this.watchClient() + this.watchRestart() + }) + + // Enable HMR for serverMiddleware + this.serverMiddlewareHMR() + + // Close hook + this.nuxt.hook('close', () => this.close()) + } + + if (this.options.build.analyze) { + this.nuxt.hook('build:done', () => { + consola.warn('Notice: Please do not deploy bundles built with "analyze" mode, they\'re for analysis purposes only.') + }) + } + + // Resolve template + this.template = vueAppTemplate + + // Create a new bundle builder + this.bundleBuilder = this.getBundleBuilder(bundleBuilder) + + this.ignore = new Ignore({ + rootDir: this.options.srcDir, + ignoreArray: this.options.ignore + }) + + // Add support for App.{ext} (or app.{ext}) + this.appFiles = [] + for (const ext of this.supportedExtensions) { + for (const name of ['app', 'App']) { + this.appFiles.push(path.join(this.options.srcDir, `${name}.${ext}`)) + } + } + } + + getBundleBuilder () { + const context = new BuildContext(this) + return new BundleBuilder(context) + } + + forGenerate () { + this.options.target = TARGETS.static + this.bundleBuilder.forGenerate() + } + + async build () { + // Avoid calling build() method multiple times when dev:true + if (this._buildStatus === STATUS.BUILD_DONE && this.options.dev) { + return this + } + // If building + if (this._buildStatus === STATUS.BUILDING) { + await waitFor(1000) + return this.build() + } + this._buildStatus = STATUS.BUILDING + + if (this.options.dev) { + consola.info('Preparing project for development') + 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 + await this.nuxt.ready() + + // Call before hook + await this.nuxt.callHook('build:before', this, this.options.build) + + // await this.validatePages() + + // Validate template + try { + this.validateTemplate() + } catch (err) { + consola.fatal(err) + } + + consola.success('Builder initialized') + + consola.debug(`App root: ${this.options.srcDir}`) + + // Create or empty .nuxt/, .nuxt/components and .nuxt/dist folders + await fsExtra.emptyDir(r(this.options.buildDir)) + const buildDirs = [r(this.options.buildDir, 'components')] + if (!this.options.dev) { + buildDirs.push( + r(this.options.buildDir, 'dist', 'client'), + r(this.options.buildDir, 'dist', 'server') + ) + } + await Promise.all(buildDirs.map(dir => fsExtra.emptyDir(dir))) + + // Call ready hook + await this.nuxt.callHook('builder:prepared', this, this.options.build) + + // Generate routes and interpret the template files + await this.generateRoutesAndFiles() + + // Add vue-app template dir to watchers + this.options.build.watch.push(this.globPathWithExtensions(this.template.dir)) + + await this.resolvePlugins() + + // Start bundle build: webpack, rollup, parcel... + await this.bundleBuilder.build() + + // Flag to set that building is done + this._buildStatus = STATUS.BUILD_DONE + + // Call done hook + await this.nuxt.callHook('build:done', this) + + return this + } + + // Check if pages dir exists and warn if not + // async validatePages () { + // this._nuxtPages = typeof this.options.build.createRoutes !== 'function' + + // if ( + // !this._nuxtPages || + // await fsExtra.exists(path.join(this.options.srcDir, this.options.dir.pages)) + // ) { + // return + // } + + // const dir = this.options.srcDir + // if (await fsExtra.exists(path.join(this.options.srcDir, '..', this.options.dir.pages))) { + // throw new Error( + // `No \`${this.options.dir.pages}\` directory found in ${dir}. Did you mean to run \`nuxt\` in the parent (\`../\`) directory?` + // ) + // } + + // this._defaultPage = true + // consola.warn(`No \`${this.options.dir.pages}\` directory found in ${dir}. Using the default built-in page.`) + // } + + validateTemplate () { + // Validate template dependencies + const templateDependencies = this.template.dependencies + for (const depName in templateDependencies) { + const depVersion = templateDependencies[depName] + + // Load installed version + const pkg = this.nuxt.resolver.requireModule(path.join(depName, 'package.json')) + if (pkg) { + const validVersion = semver.satisfies(pkg.version, depVersion) + if (!validVersion) { + consola.warn(`${depName}@${depVersion} is recommended but ${depName}@${pkg.version} is installed!`) + } + } else { + consola.warn(`${depName}@${depVersion} is required but not installed!`) + } + } + } + + globPathWithExtensions (path) { + return `${path}/**/*.{${this.supportedExtensions.join(',')}}` + } + + createTemplateContext () { + return new TemplateContext(this, this.options) + } + + async generateRoutesAndFiles () { + consola.debug('Generating nuxt files') + + this.plugins = Array.from(await this.normalizePlugins()) + + const templateContext = this.createTemplateContext() + + await this.resolvePages(templateContext) + await this.resolveApp(templateContext) + + await Promise.all([ + this.resolveLayouts(templateContext), + this.resolveStore(templateContext), + this.resolveMiddleware(templateContext) + ]) + + await this.resolvePlugins(templateContext) + + this.addOptionalTemplates(templateContext) + + await this.resolveCustomTemplates(templateContext) + + await this.resolveLoadingIndicator(templateContext) + + await this.compileTemplates(templateContext) + + consola.success('Nuxt files generated') + } + + async normalizePlugins () { + // options.extendPlugins allows for returning a new plugins array + if (typeof this.options.extendPlugins === 'function') { + const extendedPlugins = this.options.extendPlugins(this.options.plugins) + + if (Array.isArray(extendedPlugins)) { + this.options.plugins = extendedPlugins + } + } + + // extendPlugins hook only supports in-place modifying + await this.nuxt.callHook('builder:extendPlugins', this.options.plugins) + + const modes = ['client', 'server'] + const modePattern = new RegExp(`\\.(${modes.join('|')})(\\.\\w+)*$`) + return uniqBy( + this.options.plugins.map((p) => { + if (typeof p === 'string') { + p = { src: p } + } + const pluginBaseName = path.basename(p.src, path.extname(p.src)).replace( + /[^a-zA-Z?\d\s:]/g, + '' + ) + + if (p.ssr === false) { + p.mode = 'client' + } else if (p.mode === undefined) { + p.mode = 'all' + p.src.replace(modePattern, (_, mode) => { + if (modes.includes(mode)) { + p.mode = mode + } + }) + } else if (!['client', 'server', 'all'].includes(p.mode)) { + consola.warn(`Invalid plugin mode (server/client/all): '${p.mode}'. Falling back to 'all'`) + p.mode = 'all' + } + + return { + src: this.nuxt.resolver.resolveAlias(p.src), + mode: p.mode, + name: 'nuxt_plugin_' + pluginBaseName + '_' + hash(p.src) + } + }), + p => p.name + ) + } + + addOptionalTemplates (templateContext) { + if (this.options.build.indicator) { + // templateContext.templateFiles.push('components/nuxt-build-indicator.vue') + } + + if (this.options.loading !== false) { + // templateContext.templateFiles.push('components/nuxt-loading.vue') + } + } + + async resolveFiles (dir, cwd = this.options.srcDir) { + return this.ignore.filter(await glob(this.globPathWithExtensions(dir), { + cwd, + follow: this.options.build.followSymlinks + })) + } + + async resolveRelative (dir) { + const dirPrefix = new RegExp(`^${dir}/`) + return (await this.resolveFiles(dir)).map(file => ({ src: file.replace(dirPrefix, '') })) + } + + async resolveApp ({ templateVars }) { + templateVars.appPath = 'nuxt-app/app.tutorial.vue' + + for (const appFile of this.appFiles) { + if (await fsExtra.exists(appFile)) { + templateVars.appPath = appFile + templateVars.hasApp = true + return + } + } + + templateVars.hasApp = false + } + + async resolveLayouts ({ templateVars, templateFiles }) { + if (!this.options.features.layouts) { + return + } + + if (await fsExtra.exists(path.resolve(this.options.srcDir, this.options.dir.layouts))) { + for (const file of await this.resolveFiles(this.options.dir.layouts)) { + const name = file + .replace(new RegExp(`^${this.options.dir.layouts}/`), '') + .replace(new RegExp(`\\.(${this.supportedExtensions.join('|')})$`), '') + + // Layout Priority: module.addLayout > .vue file > other extensions + if (name === 'error') { + if (!templateVars.components.ErrorPage) { + templateVars.components.ErrorPage = this.relativeToBuild( + this.options.srcDir, + file + ) + } + } else if (this.options.layouts[name]) { + consola.warn(`Duplicate layout registration, "${name}" has been registered as "${this.options.layouts[name]}"`) + } else if (!templateVars.layouts[name] || /\.vue$/.test(file)) { + templateVars.layouts[name] = this.relativeToBuild( + this.options.srcDir, + file + ) + } + } + } + + // If no default layout, create its folder and add the default folder + if (!templateVars.layouts.default) { + await fsExtra.mkdirp(r(this.options.buildDir, 'layouts')) + templateFiles.push('layouts/default.vue') + templateVars.layouts.default = './layouts/default.vue' + } + } + + async resolvePages (templateContext) { + const { templateVars } = templateContext + + const pagesDir = path.join(this.options.srcDir, this.options.dir.pages) + this._nuxtPages = templateContext.hasPages = await fsExtra.exists(pagesDir) + + if (!templateContext.hasPages) { + return + } + + const { routeNameSplitter, trailingSlash } = this.options.router + + // Use nuxt.js createRoutes bases on pages/ + const files = {} + const ext = new RegExp(`\\.(${this.supportedExtensions.join('|')})$`) + for (const page of await this.resolveFiles(this.options.dir.pages)) { + const key = page.replace(ext, '') + // .vue file takes precedence over other extensions + if (/\.vue$/.test(page) || !files[key]) { + files[key] = page.replace(/(['"])/g, '\\$1') + } + } + + templateVars.router.routes = createRoutes({ + files: Object.values(files), + srcDir: this.options.srcDir, + pagesDir: this.options.dir.pages, + routeNameSplitter, + supportedExtensions: this.supportedExtensions, + trailingSlash + }) + + // TODO: Support custom createRoutes + // else { // If user defined a custom method to create routes + // templateVars.router.routes = await this.options.build.createRoutes( + // this.options.srcDir + // ) + // } + + await this.nuxt.callHook( + 'build:extendRoutes', + templateVars.router.routes, + r + ) + // router.extendRoutes method + if (typeof this.options.router.extendRoutes === 'function') { + // let the user extend the routes + const extendedRoutes = this.options.router.extendRoutes( + templateVars.router.routes, + r + ) + // Only overwrite routes when something is returned for backwards compatibility + if (extendedRoutes !== undefined) { + templateVars.router.routes = extendedRoutes + } + } + + // Make routes accessible for other modules and webpack configs + this.routes = templateVars.router.routes + } + + async resolveStore ({ templateVars, templateFiles }) { + // Add store if needed + if (!this.options.features.store || !this.options.store) { + return + } + + templateVars.storeModules = (await this.resolveRelative(this.options.dir.store)) + .sort(({ src: p1 }, { src: p2 }) => { + // modules are sorted from low to high priority (for overwriting properties) + let res = p1.split('/').length - p2.split('/').length + if (res === 0 && p1.includes('/index.')) { + res = -1 + } else if (res === 0 && p2.includes('/index.')) { + res = 1 + } + return res + }) + + templateFiles.push('store.js') + } + + async resolveMiddleware ({ templateVars, templateFiles }) { + if (!this.options.features.middleware) { + return + } + + const middleware = await this.resolveRelative(this.options.dir.middleware) + const extRE = new RegExp(`\\.(${this.supportedExtensions.join('|')})$`) + templateVars.middleware = middleware.map(({ src }) => { + const name = src.replace(extRE, '') + const dst = this.relativeToBuild(this.options.srcDir, this.options.dir.middleware, src) + return { name, src, dst } + }) + + // templateFiles.push('middleware.js') + } + + async resolveCustomTemplates (templateContext) { + // Sanitize custom template files + this.options.build.templates = this.options.build.templates.map((t) => { + const src = t.src || t + return { + src: r(this.options.srcDir, src), + dst: t.dst || path.basename(src), + custom: true, + ...(typeof t === 'object' ? t : undefined) + } + }) + + const customTemplateFiles = this.options.build.templates.map(t => t.dst || path.basename(t.src || t)) + + const templatePaths = uniq([ + // Modules & user provided templates + // first custom to keep their index + ...customTemplateFiles, + // @nuxt/vue-app templates + ...templateContext.templateFiles + ]) + + const appDir = path.resolve(this.options.srcDir, this.options.dir.app) + + templateContext.templateFiles = await Promise.all(templatePaths.map(async (file) => { + // Use custom file if provided in build.templates[] + const customTemplateIndex = customTemplateFiles.indexOf(file) + const customTemplate = customTemplateIndex !== -1 ? this.options.build.templates[customTemplateIndex] : null + let src = customTemplate ? (customTemplate.src || customTemplate) : r(this.template.dir, file) + + // Allow override templates using a file with same name in ${srcDir}/app + const customAppFile = path.resolve(this.options.srcDir, this.options.dir.app, file) + const customAppFileExists = customAppFile.startsWith(appDir) && await fsExtra.exists(customAppFile) + if (customAppFileExists) { + src = customAppFile + } + + return { + src, + dst: file, + custom: Boolean(customAppFileExists || customTemplate), + options: (customTemplate && customTemplate.options) || {} + } + })) + } + + async resolveLoadingIndicator ({ templateFiles }) { + if (!this.options.loadingIndicator.name) { + return + } + let indicatorPath = path.resolve( + this.template.dir, + 'views/loading', + this.options.loadingIndicator.name + '.html' + ) + + let customIndicator = false + if (!await fsExtra.exists(indicatorPath)) { + indicatorPath = this.nuxt.resolver.resolveAlias( + this.options.loadingIndicator.name + ) + + if (await fsExtra.exists(indicatorPath)) { + customIndicator = true + } else { + indicatorPath = null + } + } + + if (!indicatorPath) { + // TODO + // consola.error( + // `Could not fetch loading indicator: ${ + // this.options.loadingIndicator.name + // }` + // ) + return + } + + templateFiles.push({ + src: indicatorPath, + dst: 'loading.html', + custom: customIndicator, + options: this.options.loadingIndicator + }) + } + + async compileTemplates (templateContext) { + // Prepare template options + const { templateVars, templateFiles, templateOptions } = templateContext + + await this.nuxt.callHook('build:templates', { + templateVars, + templatesFiles: templateFiles, + resolve: r + }) + + templateOptions.imports = { + ...templateOptions.imports, + resolvePath: this.nuxt.resolver.resolvePath, + resolveAlias: this.nuxt.resolver.resolveAlias, + relativeToBuild: this.relativeToBuild + } + + // Interpret and move template files to .nuxt/ + await Promise.all( + templateFiles.map(async (templateFile) => { + const { src, dst, custom } = templateFile + + // Add custom templates to watcher + if (custom) { + this.options.build.watch.push(src) + } + + // Render template to dst + const fileContent = await fsExtra.readFile(src, 'utf8') + + let content + try { + const templateFunction = template(fileContent, templateOptions) + content = stripWhitespace( + templateFunction({ + ...templateVars, + ...templateFile + }) + ) + } catch (err) { + throw new Error(`Could not compile template ${src}: ${err.message}`) + } + + // Ensure parent dir exits and write file + const relativePath = r(this.options.buildDir, dst) + await fsExtra.outputFile(relativePath, content, 'utf8') + }) + ) + } + + resolvePlugins () { + // Check plugins exist then set alias to their real path + return Promise.all(this.plugins.map(async (p) => { + const ext = '{?(.+([^.])),/index.+([^.])}' + const pluginFiles = await glob(`${p.src}${ext}`) + + if (!pluginFiles || pluginFiles.length === 0) { + throw new Error(`Plugin not found: ${p.src}`) + } + + if (pluginFiles.length > 1 && !isIndexFileAndFolder(pluginFiles)) { + consola.warn({ + message: `Found ${pluginFiles.length} plugins that match the configuration, suggest to specify extension:`, + additional: '\n' + pluginFiles.map(x => `- ${x}`).join('\n') + }) + } + + p.src = this.relativeToBuild(p.src) + })) + } + + // TODO: Uncomment when generateConfig enabled again + // async generateConfig() { + // const config = path.resolve(this.options.buildDir, 'build.config.js') + // const options = omit(this.options, Options.unsafeKeys) + // await fsExtra.writeFile( + // config, + // `export default ${JSON.stringify(options, null, ' ')}`, + // 'utf8' + // ) + // } + + createFileWatcher (patterns, events, listener, watcherCreatedCallback) { + const options = this.options.watchers.chokidar + const watcher = chokidar.watch(patterns, options) + + for (const event of events) { + watcher.on(event, listener) + } + + // TODO: due to fixes in chokidar this isnt used anymore and could be removed in Nuxt v3 + const { rewatchOnRawEvents } = this.options.watchers + if (rewatchOnRawEvents && Array.isArray(rewatchOnRawEvents)) { + watcher.on('raw', (_event) => { + if (rewatchOnRawEvents.includes(_event)) { + watcher.close() + + listener() + this.createFileWatcher(patterns, events, listener, watcherCreatedCallback) + } + }) + } + + if (typeof watcherCreatedCallback === 'function') { + watcherCreatedCallback(watcher) + } + } + + assignWatcher (key) { + return (watcher) => { + if (this.watchers[key]) { + this.watchers[key].close() + } + this.watchers[key] = watcher + } + } + + watchClient () { + let patterns = [ + r(this.options.srcDir, this.options.dir.layouts), + r(this.options.srcDir, this.options.dir.middleware), + ...this.appFiles + ] + + if (this.options.store) { + patterns.push(r(this.options.srcDir, this.options.dir.store)) + } + + if (this._nuxtPages && !this._defaultPage) { + patterns.push(r(this.options.srcDir, this.options.dir.pages)) + } + + patterns = patterns.map((path, ...args) => upath.normalizeSafe(this.globPathWithExtensions(path), ...args)) + + const refreshFiles = debounce(() => this.generateRoutesAndFiles(), 200) + + // Watch for src Files + this.createFileWatcher(patterns, ['add', 'unlink'], refreshFiles, this.assignWatcher('files')) + + // Watch for custom provided files + const customPatterns = uniq([ + ...this.options.build.watch, + ...Object.values(omit(this.options.build.styleResources, ['options'])) + ]).map(upath.normalizeSafe) + + if (customPatterns.length === 0) { + return + } + + this.createFileWatcher(customPatterns, ['change'], refreshFiles, this.assignWatcher('custom')) + + // Watch for app/ files + this.createFileWatcher([r(this.options.srcDir, this.options.dir.app)], ['add', 'change', 'unlink'], refreshFiles, this.assignWatcher('app')) + } + + serverMiddlewareHMR () { + // Check nuxt.server dependency + if (!this.nuxt.server) { + return + } + + // Get registered server middleware with path + const entries = this.nuxt.server.serverMiddlewarePaths() + + // Resolve dependency tree + const deps = new Set() + const dep2Entry = {} + + for (const entry of entries) { + for (const dep of scanRequireTree(entry)) { + deps.add(dep) + if (!dep2Entry[dep]) { + dep2Entry[dep] = new Set() + } + dep2Entry[dep].add(entry) + } + } + + // Create watcher + this.createFileWatcher( + Array.from(deps), + ['all'], + debounce((event, fileName) => { + if (!dep2Entry[fileName]) { + return // #7097 + } + for (const entry of dep2Entry[fileName]) { + // Reload entry + let newItem + try { + newItem = this.nuxt.server.replaceMiddleware(entry, entry) + } catch (error) { + consola.error(error) + consola.error(`[HMR Error]: ${error}`) + } + + if (!newItem) { + // Full reload if HMR failed + return this.nuxt.callHook('watch:restart', { event, path: fileName }) + } + + // Log + consola.info(`[HMR] ${chalk.cyan(newItem.route || '/')} (${chalk.grey(fileName)})`) + } + // Tree may be changed so recreate watcher + this.serverMiddlewareHMR() + }, 200), + this.assignWatcher('serverMiddleware') + ) + } + + watchRestart () { + const nuxtRestartWatch = [ + // Custom watchers + ...this.options.watch + ].map(this.nuxt.resolver.resolveAlias) + + if (this.ignore.ignoreFile) { + nuxtRestartWatch.push(this.ignore.ignoreFile) + } + + if (this.options._envConfig && this.options._envConfig.dotenv) { + nuxtRestartWatch.push(this.options._envConfig.dotenv) + } + + // If default page displayed, watch for first page creation + if (this._nuxtPages && this._defaultPage) { + nuxtRestartWatch.push(path.join(this.options.srcDir, this.options.dir.pages)) + } + // If store not activated, watch for a file in the directory + if (!this.options.store) { + nuxtRestartWatch.push(path.join(this.options.srcDir, this.options.dir.store)) + } + + this.createFileWatcher( + nuxtRestartWatch, + ['all'], + async (event, fileName) => { + if (['add', 'change', 'unlink'].includes(event) === false) { + return + } + await this.nuxt.callHook('watch:fileChanged', this, fileName) // Legacy + await this.nuxt.callHook('watch:restart', { event, path: fileName }) + }, + this.assignWatcher('restart') + ) + } + + unwatch () { + for (const watcher in this.watchers) { + this.watchers[watcher].close() + } + } + + async close () { + if (this.__closed) { + return + } + this.__closed = true + + // Unwatch + this.unwatch() + + // Close bundleBuilder + if (typeof this.bundleBuilder.close === 'function') { + await this.bundleBuilder.close() + } + } +} + +const STATUS = { + INITIAL: 1, + BUILD_DONE: 2, + BUILDING: 3 +} diff --git a/packages/nuxt3/src/builder/context/build.ts b/packages/nuxt3/src/builder/context/build.ts new file mode 100644 index 0000000000..9f0931e7f8 --- /dev/null +++ b/packages/nuxt3/src/builder/context/build.ts @@ -0,0 +1,16 @@ +export default class BuildContext { + constructor (builder) { + this._builder = builder + this.nuxt = builder.nuxt + this.options = builder.nuxt.options + this.target = builder.nuxt.options.target + } + + get buildOptions () { + return this.options.build + } + + get plugins () { + return this._builder.plugins + } +} diff --git a/packages/nuxt3/src/builder/context/template.ts b/packages/nuxt3/src/builder/context/template.ts new file mode 100644 index 0000000000..2ffb23ccb6 --- /dev/null +++ b/packages/nuxt3/src/builder/context/template.ts @@ -0,0 +1,70 @@ +import hash from 'hash-sum' +import consola from 'consola' +import uniqBy from 'lodash/uniqBy' +import serialize from 'serialize-javascript' + +import devalue from '@nuxt/devalue' +import { r, wp, wChunk, serializeFunction, isFullStatic } from 'src/utils' + +export default class TemplateContext { + constructor(builder, options) { + this.templateFiles = Array.from(builder.template.files) + this.templateVars = { + nuxtOptions: options, + features: options.features, + extensions: options.extensions + .map(ext => ext.replace(/^\./, '')) + .join('|'), + messages: options.messages, + splitChunks: options.build.splitChunks, + uniqBy, + isDev: options.dev, + isTest: options.test, + isFullStatic: isFullStatic(options), + debug: options.debug, + buildIndicator: options.dev && options.build.indicator, + vue: { config: options.vue.config }, + fetch: options.fetch, + mode: options.mode, + router: options.router, + env: options.env, + head: options.head, + store: options.features.store ? options.store : false, + globalName: options.globalName, + globals: builder.globals, + css: options.css, + plugins: builder.plugins, + appPath: './App.js', + layouts: Object.assign({}, options.layouts), + loading: + typeof options.loading === 'string' + ? builder.relativeToBuild(options.srcDir, options.loading) + : options.loading, + pageTransition: options.pageTransition, + layoutTransition: options.layoutTransition, + rootDir: options.rootDir, + srcDir: options.srcDir, + dir: options.dir, + components: { + ErrorPage: options.ErrorPage + ? builder.relativeToBuild(options.ErrorPage) + : null + } + } + } + + get templateOptions () { + return { + imports: { + serialize, + serializeFunction, + devalue, + hash, + r, + wp, + wChunk, + }, + interpolate: /<%=([\s\S]+?)%>/g + } + } +} diff --git a/packages/nuxt3/src/builder/ignore.ts b/packages/nuxt3/src/builder/ignore.ts new file mode 100644 index 0000000000..d929368960 --- /dev/null +++ b/packages/nuxt3/src/builder/ignore.ts @@ -0,0 +1,59 @@ +import path from 'path' +import fs from 'fs-extra' +import ignore from 'ignore' + +export default class Ignore { + constructor (options) { + this.rootDir = options.rootDir + this.ignoreOptions = options.ignoreOptions + this.ignoreArray = options.ignoreArray + this.addIgnoresRules() + } + + static get IGNORE_FILENAME () { + return '.nuxtignore' + } + + findIgnoreFile () { + if (!this.ignoreFile) { + const ignoreFile = path.resolve(this.rootDir, Ignore.IGNORE_FILENAME) + if (fs.existsSync(ignoreFile) && fs.statSync(ignoreFile).isFile()) { + this.ignoreFile = ignoreFile + this.ignore = ignore(this.ignoreOptions) + } + } + return this.ignoreFile + } + + readIgnoreFile () { + if (this.findIgnoreFile()) { + return fs.readFileSync(this.ignoreFile, 'utf8') + } + } + + addIgnoresRules () { + const content = this.readIgnoreFile() + if (content) { + this.ignore.add(content) + } + if (this.ignoreArray && this.ignoreArray.length > 0) { + if (!this.ignore) { + this.ignore = ignore(this.ignoreOptions) + } + this.ignore.add(this.ignoreArray) + } + } + + filter (paths) { + if (this.ignore) { + return this.ignore.filter([].concat(paths || [])) + } + return paths + } + + reload () { + delete this.ignore + delete this.ignoreFile + this.addIgnoresRules() + } +} diff --git a/packages/nuxt3/src/builder/index.ts b/packages/nuxt3/src/builder/index.ts new file mode 100644 index 0000000000..e00c399dec --- /dev/null +++ b/packages/nuxt3/src/builder/index.ts @@ -0,0 +1,10 @@ +import Builder from './builder' +export { default as Builder } from './builder' + +export function getBuilder (nuxt) { + return new Builder(nuxt) +} + +export function build (nuxt) { + return getBuilder(nuxt).build() +} diff --git a/packages/nuxt3/src/cli/command.ts b/packages/nuxt3/src/cli/command.ts new file mode 100644 index 0000000000..4c68b3fd0f --- /dev/null +++ b/packages/nuxt3/src/cli/command.ts @@ -0,0 +1,237 @@ + +import path from 'path' +import consola from 'consola' +import minimist from 'minimist' +import Hookable from 'hable' +import { name, version } from '../../package.json' +import { forceExit } from './utils' +import { loadNuxtConfig } from './utils/config' +import { indent, foldLines, colorize } from './utils/formatting' +import { startSpaces, optionSpaces, forceExitTimeout } from './utils/constants' +import { Nuxt } from 'src/core' +import { Builder } from 'src/builder' +import { Generator } from 'src/generator' + +export default class NuxtCommand extends Hookable { + constructor (cmd = { name: '', usage: '', description: '' }, argv = process.argv.slice(2), hooks = {}) { + super(consola) + this.addHooks(hooks) + + if (!cmd.options) { + cmd.options = {} + } + this.cmd = cmd + + this._argv = Array.from(argv) + this._parsedArgv = null // Lazy evaluate + } + + static run (cmd, argv, hooks) { + return NuxtCommand.from(cmd, argv, hooks).run() + } + + static from (cmd, argv, hooks) { + if (cmd instanceof NuxtCommand) { + return cmd + } + return new NuxtCommand(cmd, argv, hooks) + } + + async run () { + await this.callHook('run:before', { + argv: this._argv, + cmd: this.cmd, + rootDir: path.resolve(this.argv._[0] || '.') + }) + + if (this.argv.help) { + this.showHelp() + return + } + + if (this.argv.version) { + this.showVersion() + return + } + + if (typeof this.cmd.run !== 'function') { + throw new TypeError('Invalid command! Commands should at least implement run() function.') + } + + let cmdError + + try { + await this.cmd.run(this) + } catch (e) { + cmdError = e + } + + if (this.argv.lock) { + await this.releaseLock() + } + + if (this.argv['force-exit']) { + const forceExitByUser = this.isUserSuppliedArg('force-exit') + if (cmdError) { + consola.fatal(cmdError) + } + forceExit(this.cmd.name, forceExitByUser ? false : forceExitTimeout) + if (forceExitByUser) { + return + } + } + + if (cmdError) { + throw cmdError + } + } + + showVersion () { + process.stdout.write(`${name} v${version}\n`) + } + + showHelp () { + process.stdout.write(this._getHelp()) + } + + get argv () { + if (!this._parsedArgv) { + const minimistOptions = this._getMinimistOptions() + this._parsedArgv = minimist(this._argv, minimistOptions) + } + return this._parsedArgv + } + + async getNuxtConfig (extraOptions = {}) { + // Flag to indicate nuxt is running with CLI (not programmatic) + extraOptions._cli = true + + const context = { + command: this.cmd.name, + dev: !!extraOptions.dev + } + + const config = await loadNuxtConfig(this.argv, context) + const options = Object.assign(config, extraOptions) + + for (const name of Object.keys(this.cmd.options)) { + this.cmd.options[name].prepare && this.cmd.options[name].prepare(this, options, this.argv) + } + + await this.callHook('config', options) + + return options + } + + async getNuxt (options) { + + const nuxt = new Nuxt(options) + await nuxt.ready() + + return nuxt + } + + async getBuilder (nuxt) { + return new Builder(nuxt) + } + + async getGenerator (nuxt) { + const builder = await this.getBuilder(nuxt) + return new Generator(nuxt, builder) + } + + async setLock (lockRelease) { + if (lockRelease) { + if (this._lockRelease) { + consola.warn(`A previous unreleased lock was found, this shouldn't happen and is probably an error in 'nuxt ${this.cmd.name}' command. The lock will be removed but be aware of potential strange results`) + + await this.releaseLock() + this._lockRelease = lockRelease + } else { + this._lockRelease = lockRelease + } + } + } + + async releaseLock () { + if (this._lockRelease) { + await this._lockRelease() + this._lockRelease = undefined + } + } + + isUserSuppliedArg (option) { + return this._argv.includes(`--${option}`) || this._argv.includes(`--no-${option}`) + } + + _getDefaultOptionValue (option) { + return typeof option.default === 'function' ? option.default(this.cmd) : option.default + } + + _getMinimistOptions () { + const minimistOptions = { + alias: {}, + boolean: [], + string: [], + default: {} + } + + for (const name of Object.keys(this.cmd.options)) { + const option = this.cmd.options[name] + + if (option.alias) { + minimistOptions.alias[option.alias] = name + } + if (option.type) { + minimistOptions[option.type].push(option.alias || name) + } + if (option.default) { + minimistOptions.default[option.alias || name] = this._getDefaultOptionValue(option) + } + } + + return minimistOptions + } + + _getHelp () { + const options = [] + let maxOptionLength = 0 + + for (const name in this.cmd.options) { + const option = this.cmd.options[name] + + let optionHelp = '--' + optionHelp += option.type === 'boolean' && this._getDefaultOptionValue(option) ? 'no-' : '' + optionHelp += name + if (option.alias) { + optionHelp += `, -${option.alias}` + } + + maxOptionLength = Math.max(maxOptionLength, optionHelp.length) + options.push([optionHelp, option.description]) + } + + const _opts = options.map(([option, description]) => { + const i = indent(maxOptionLength + optionSpaces - option.length) + return foldLines( + option + i + description, + startSpaces + maxOptionLength + optionSpaces * 2, + startSpaces + optionSpaces + ) + }).join('\n') + + const usage = foldLines(`Usage: nuxt ${this.cmd.usage} [options]`, startSpaces) + const description = foldLines(this.cmd.description, startSpaces) + const opts = foldLines('Options:', startSpaces) + '\n\n' + _opts + + let helpText = colorize(`${usage}\n\n`) + if (this.cmd.description) { + helpText += colorize(`${description}\n\n`) + } + if (options.length) { + helpText += colorize(`${opts}\n\n`) + } + + return helpText + } +} diff --git a/packages/nuxt3/src/cli/commands/build.ts b/packages/nuxt3/src/cli/commands/build.ts new file mode 100644 index 0000000000..8f3eab5eb0 --- /dev/null +++ b/packages/nuxt3/src/cli/commands/build.ts @@ -0,0 +1,92 @@ +import consola from 'consola' +import { MODES, TARGETS } from 'src/utils' +import { common, locking } from '../options' +import { createLock } from '../utils' + +export default { + name: 'build', + description: 'Compiles the application for production deployment', + usage: 'build ', + options: { + ...common, + ...locking, + analyze: { + alias: 'a', + type: 'boolean', + description: 'Launch webpack-bundle-analyzer to optimize your bundles', + prepare (cmd, options, argv) { + // Analyze option + options.build = options.build || {} + if (argv.analyze && typeof options.build.analyze !== 'object') { + options.build.analyze = true + } + } + }, + devtools: { + type: 'boolean', + default: false, + description: 'Enable Vue devtools', + prepare (cmd, options, argv) { + options.vue = options.vue || {} + options.vue.config = options.vue.config || {} + if (argv.devtools) { + options.vue.config.devtools = true + } + } + }, + generate: { + type: 'boolean', + default: true, + description: 'Don\'t generate static version for SPA mode (useful for nuxt start)' + }, + quiet: { + alias: 'q', + type: 'boolean', + description: 'Disable output except for errors', + prepare (cmd, options, argv) { + // Silence output when using --quiet + options.build = options.build || {} + if (argv.quiet) { + options.build.quiet = Boolean(argv.quiet) + } + } + }, + standalone: { + type: 'boolean', + default: false, + description: 'Bundle all server dependencies (useful for nuxt-start)', + prepare (cmd, options, argv) { + if (argv.standalone) { + options.build.standalone = true + } + } + } + }, + async run (cmd) { + const config = await cmd.getNuxtConfig({ dev: false, server: false, _build: true }) + config.server = (config.mode === MODES.spa || config.ssr === false) && cmd.argv.generate !== false + const nuxt = await cmd.getNuxt(config) + + if (cmd.argv.lock) { + await cmd.setLock(await createLock({ + id: 'build', + dir: nuxt.options.buildDir, + root: config.rootDir + })) + } + + // 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 }) + } else { + // 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) + '`') + } + } +} diff --git a/packages/nuxt3/src/cli/commands/dev.ts b/packages/nuxt3/src/cli/commands/dev.ts new file mode 100644 index 0000000000..9674bca052 --- /dev/null +++ b/packages/nuxt3/src/cli/commands/dev.ts @@ -0,0 +1,116 @@ +import consola from 'consola' +import chalk from 'chalk' +import opener from 'opener' +import { common, server } from '../options' +import { eventsMapping, formatPath } from '../utils' +import { showBanner } from '../utils/banner' +import { showMemoryUsage } from '../utils/memory' + +export default { + name: 'dev', + description: 'Start the application in development mode (e.g. hot-code reloading, error reporting)', + usage: 'dev ', + options: { + ...common, + ...server, + open: { + alias: 'o', + type: 'boolean', + description: 'Opens the server listeners url in the default browser' + } + }, + + async run (cmd) { + const { argv } = cmd + + await this.startDev(cmd, argv, argv.open) + }, + + async startDev (cmd, argv) { + let nuxt + try { + nuxt = await this._listenDev(cmd, argv) + } catch (error) { + consola.fatal(error) + return + } + + try { + await this._buildDev(cmd, argv, nuxt) + } catch (error) { + await nuxt.callHook('cli:buildError', error) + consola.error(error) + } + + return nuxt + }, + + async _listenDev (cmd, argv) { + const config = await cmd.getNuxtConfig({ dev: true, _build: true }) + const nuxt = await cmd.getNuxt(config) + + // Setup hooks + nuxt.hook('watch:restart', payload => this.onWatchRestart(payload, { nuxt, cmd, argv })) + nuxt.hook('bundler:change', changedFileName => this.onBundlerChange(changedFileName)) + + // Wait for nuxt to be ready + await nuxt.ready() + + // Start listening + await nuxt.server.listen() + + // Show banner when listening + showBanner(nuxt, false) + + // Opens the server listeners url in the default browser (only once) + if (argv.open) { + argv.open = false + const openerPromises = nuxt.server.listeners.map(listener => opener(listener.url)) + await Promise.all(openerPromises) + } + + // Return instance + return nuxt + }, + + async _buildDev (cmd, argv, nuxt) { + // Create builder instance + const builder = await cmd.getBuilder(nuxt) + + // Start Build + await builder.build() + + // Print memory usage + showMemoryUsage() + + // Display server urls after the build + for (const listener of nuxt.server.listeners) { + consola.info(chalk.bold('Listening on: ') + listener.url) + } + + // Return instance + return nuxt + }, + + logChanged ({ event, path }) { + const { icon, color, action } = eventsMapping[event] || eventsMapping.change + + consola.log({ + type: event, + icon: chalk[color].bold(icon), + message: `${action} ${chalk.cyan(formatPath(path))}` + }) + }, + + async onWatchRestart ({ event, path }, { nuxt, cmd, argv }) { + this.logChanged({ event, path }) + + await nuxt.close() + + await this.startDev(cmd, argv) + }, + + onBundlerChange (path) { + this.logChanged({ event: 'change', path }) + } +} diff --git a/packages/nuxt3/src/cli/commands/export.ts b/packages/nuxt3/src/cli/commands/export.ts new file mode 100644 index 0000000000..58ce739b57 --- /dev/null +++ b/packages/nuxt3/src/cli/commands/export.ts @@ -0,0 +1,50 @@ +import path from 'path' +import consola from 'consola' +import { TARGETS } from 'src/utils' +import { common, locking } from '../options' +import { createLock } from '../utils' + +export default { + name: 'export', + description: 'Export a static generated web application', + usage: 'export ', + 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, + _export: true + }) + 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(0) + + 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') + } +} diff --git a/packages/nuxt3/src/cli/commands/generate.ts b/packages/nuxt3/src/cli/commands/generate.ts new file mode 100644 index 0000000000..206bb796ba --- /dev/null +++ b/packages/nuxt3/src/cli/commands/generate.ts @@ -0,0 +1,110 @@ +import { TARGETS } from 'src/utils' +import { common, locking } from '../options' +import { normalizeArg, createLock } from '../utils' + +export default { + name: 'generate', + description: 'Generate a static web application (server-rendered)', + usage: 'generate ', + options: { + ...common, + ...locking, + build: { + type: 'boolean', + default: true, + description: 'Only generate pages for dynamic routes, used for incremental builds. Generate has to be run once without this option before using it' + }, + devtools: { + type: 'boolean', + default: false, + description: 'Enable Vue devtools', + prepare (cmd, options, argv) { + options.vue = options.vue || {} + options.vue.config = options.vue.config || {} + if (argv.devtools) { + options.vue.config.devtools = true + } + } + }, + quiet: { + alias: 'q', + type: 'boolean', + description: 'Disable output except for errors', + prepare (cmd, options, argv) { + // Silence output when using --quiet + options.build = options.build || {} + if (argv.quiet) { + options.build.quiet = true + } + } + }, + modern: { + ...common.modern, + description: 'Generate app in modern build (modern mode can be only client)', + prepare (cmd, options, argv) { + if (normalizeArg(argv.modern)) { + options.modern = 'client' + } + } + }, + 'fail-on-error': { + type: 'boolean', + default: false, + description: 'Exit with non-zero status code if there are errors when generating pages' + } + }, + async run (cmd) { + const config = await cmd.getNuxtConfig({ + dev: false, + _build: cmd.argv.build, + _generate: true + }) + + 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 + 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) { + await cmd.setLock(await createLock({ + id: 'build', + dir: nuxt.options.buildDir, + root: config.rootDir + })) + + nuxt.hook('build:done', async () => { + await cmd.releaseLock() + + await cmd.setLock(await createLock({ + id: 'generate', + dir: nuxt.options.generate.dir, + root: config.rootDir + })) + }) + } + + const generator = await cmd.getGenerator(nuxt) + await nuxt.server.listen(0) + + 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') + } + } +} diff --git a/packages/nuxt3/src/cli/commands/help.ts b/packages/nuxt3/src/cli/commands/help.ts new file mode 100644 index 0000000000..4b702cd1df --- /dev/null +++ b/packages/nuxt3/src/cli/commands/help.ts @@ -0,0 +1,29 @@ +import consola from 'consola' +import listCommands from '../list' +import { common } from '../options' +import NuxtCommand from '../command' +import getCommand from '.' + +export default { + name: 'help', + description: 'Shows help for ', + usage: 'help ', + options: { + help: common.help, + version: common.version + }, + async run (cmd) { + const [name] = cmd._argv + if (!name) { + return listCommands() + } + const command = await getCommand(name) + + if (!command) { + consola.info(`Unknown command: ${name}`) + return + } + + NuxtCommand.from(command).showHelp() + } +} diff --git a/packages/nuxt3/src/cli/commands/index.ts b/packages/nuxt3/src/cli/commands/index.ts new file mode 100644 index 0000000000..50e84854d4 --- /dev/null +++ b/packages/nuxt3/src/cli/commands/index.ts @@ -0,0 +1,17 @@ +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') +} + +export default function getCommand (name) { + if (!_commands[name]) { + return Promise.resolve(null) + } + return _commands[name]().then(m => m.default) +} diff --git a/packages/nuxt3/src/cli/commands/serve.ts b/packages/nuxt3/src/cli/commands/serve.ts new file mode 100644 index 0000000000..b48b66a193 --- /dev/null +++ b/packages/nuxt3/src/cli/commands/serve.ts @@ -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 'src/config' +import { TARGETS } from 'src/utils' +import { common, server } from '../options' +import { showBanner } from '../utils/banner' +import { Listener } from 'src/server' +import { Nuxt } from 'src/core' + +export default { + name: 'serve', + description: 'Serve the exported static application (should be compiled with `nuxt build` and `nuxt export` first)', + usage: 'serve ', + 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( + options.router.base, + 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() + showBanner({ + constructor: Nuxt, + options, + server: { + listeners: [listener] + } + }, false) + } +} diff --git a/packages/nuxt3/src/cli/commands/start.ts b/packages/nuxt3/src/cli/commands/start.ts new file mode 100644 index 0000000000..b35cd6a063 --- /dev/null +++ b/packages/nuxt3/src/cli/commands/start.ts @@ -0,0 +1,24 @@ +import { TARGETS } from 'src/utils' +import { common, server } from '../options' +import { showBanner } from '../utils/banner' + +export default { + name: 'start', + description: 'Start the application in production mode (the application should be compiled with `nuxt build` first)', + usage: 'start ', + options: { + ...common, + ...server + }, + 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 + await nuxt.server.listen() + showBanner(nuxt) + } +} diff --git a/packages/nuxt3/src/cli/commands/webpack.ts b/packages/nuxt3/src/cli/commands/webpack.ts new file mode 100644 index 0000000000..5d453f3308 --- /dev/null +++ b/packages/nuxt3/src/cli/commands/webpack.ts @@ -0,0 +1,114 @@ +import util from 'util' +import consola from 'consola' +import get from 'lodash/get' +import { common } from '../options' + +export default { + name: 'webpack', + description: 'Inspect Nuxt webpack config', + usage: 'webpack [query...]', + options: { + ...common, + name: { + alias: 'n', + type: 'string', + default: 'client', + description: 'Webpack bundle name: server, client, modern' + }, + depth: { + alias: 'd', + type: 'string', + default: 2, + description: 'Inspection depth' + }, + colors: { + type: 'boolean', + default: process.stdout.isTTY, + description: 'Output with ANSI colors' + }, + dev: { + type: 'boolean', + default: false, + description: 'Inspect development mode webpack config' + } + }, + async run (cmd) { + const { name } = cmd.argv + const queries = [...cmd.argv._] + + const config = await cmd.getNuxtConfig({ dev: cmd.argv.dev, server: false }) + const nuxt = await cmd.getNuxt(config) + const builder = await cmd.getBuilder(nuxt) + const { bundleBuilder } = builder + const webpackConfig = bundleBuilder.getWebpackConfig(name) + + let queryError + const match = queries.reduce((result, query) => { + const m = advancedGet(result, query) + if (m === undefined) { + queryError = query + return result + } + return m + }, webpackConfig) + + const serialized = formatObj(match, { + depth: parseInt(cmd.argv.depth), + colors: cmd.argv.colors + }) + + consola.log(serialized + '\n') + + if (serialized.includes('[Object]' || serialized.includes('[Array'))) { + consola.info('You can use `--depth` or add more queries to inspect `[Object]` and `[Array]` fields.') + } + + if (queryError) { + consola.warn(`No match in webpack config for \`${queryError}\``) + } + } +} + +function advancedGet (obj = {}, query = '') { + let result = obj + + if (!query || !result) { + return result + } + + const [l, r] = query.split('=') + + if (!Array.isArray(result)) { + return typeof result === 'object' ? get(result, l) : result + } + + result = result.filter((i) => { + const v = get(i, l) + + if (!v) { + return + } + + if ( + (v === r) || + (typeof v.test === 'function' && v.test(r)) || + (typeof v.match === 'function' && v.match(r)) || + (r && r.match(v)) + ) { + return true + } + }) + + if (result.length === 1) { + return result[0] + } + + return result.length ? result : undefined +} + +function formatObj (obj, formatOptions) { + if (!util.formatWithOptions) { + return util.format(obj) + } + return util.formatWithOptions(formatOptions, obj) +} diff --git a/packages/nuxt3/src/cli/index.ts b/packages/nuxt3/src/cli/index.ts new file mode 100644 index 0000000000..7c8d3d81b1 --- /dev/null +++ b/packages/nuxt3/src/cli/index.ts @@ -0,0 +1,13 @@ +import * as commands from './commands' +import * as options from './options' + +export { + commands, + options +} + +export { default as NuxtCommand } from './command' +export { default as setup } from './setup' +export { default as run } from './run' +export { loadNuxtConfig } from './utils/config' +export { getWebpackConfig } from './utils/webpack' diff --git a/packages/nuxt3/src/cli/list.ts b/packages/nuxt3/src/cli/list.ts new file mode 100644 index 0000000000..13d21d997a --- /dev/null +++ b/packages/nuxt3/src/cli/list.ts @@ -0,0 +1,35 @@ +import chalk from 'chalk' +import { indent, foldLines, colorize } from './utils/formatting' +import { startSpaces, optionSpaces } from './utils/constants' +import getCommand from './commands' + +export default async function listCommands () { + const commandsOrder = ['dev', 'build', 'generate', 'start', 'help'] + + // Load all commands + const _commands = await Promise.all( + commandsOrder.map(cmd => getCommand(cmd)) + ) + + let maxLength = 0 + const commandsHelp = [] + + for (const command of _commands) { + commandsHelp.push([command.usage, command.description]) + maxLength = Math.max(maxLength, command.usage.length) + } + + const _cmds = commandsHelp.map(([cmd, description]) => { + const i = indent(maxLength + optionSpaces - cmd.length) + return foldLines( + chalk.green(cmd) + i + description, + startSpaces + maxLength + optionSpaces * 2, + startSpaces + optionSpaces + ) + }).join('\n') + + const usage = foldLines('Usage: nuxt [--help|-h]', startSpaces) + const cmds = foldLines('Commands:', startSpaces) + '\n\n' + _cmds + + process.stderr.write(colorize(`${usage}\n\n${cmds}\n\n`)) +} diff --git a/packages/nuxt3/src/cli/options/common.ts b/packages/nuxt3/src/cli/options/common.ts new file mode 100644 index 0000000000..ea5f8034b8 --- /dev/null +++ b/packages/nuxt3/src/cli/options/common.ts @@ -0,0 +1,68 @@ +import { defaultNuxtConfigFile } from 'src/config' +import { normalizeArg } from '../utils' + +export default { + spa: { + alias: 's', + type: 'boolean', + description: 'Launch in SPA mode' + }, + universal: { + alias: 'u', + type: 'boolean', + description: 'Launch in Universal mode (default)' + }, + 'config-file': { + alias: 'c', + type: 'string', + default: defaultNuxtConfigFile, + description: `Path to Nuxt.js config file (default: \`${defaultNuxtConfigFile}\`)` + }, + modern: { + alias: 'm', + type: 'string', + description: 'Build/Start app for modern browsers, e.g. server, client and false', + prepare (cmd, options, argv) { + if (argv.modern !== undefined) { + options.modern = normalizeArg(argv.modern) + } + } + }, + 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', 'export'].includes(cmd.name) + }, + description: 'Whether Nuxt.js should force exit after the command has finished' + }, + version: { + alias: 'v', + type: 'boolean', + description: 'Display the Nuxt version' + }, + help: { + alias: 'h', + type: 'boolean', + description: 'Display this message' + }, + processenv: { + type: 'boolean', + default: true, + description: 'Disable reading from `process.env` and updating it with dotenv' + }, + dotenv: { + type: 'string', + default: '.env', + description: 'Specify path to dotenv file (default: `.env`). Use `false` to disable' + } +} diff --git a/packages/nuxt3/src/cli/options/index.ts b/packages/nuxt3/src/cli/options/index.ts new file mode 100644 index 0000000000..1d2420967e --- /dev/null +++ b/packages/nuxt3/src/cli/options/index.ts @@ -0,0 +1,9 @@ +import common from './common' +import server from './server' +import locking from './locking' + +export { + common, + server, + locking +} diff --git a/packages/nuxt3/src/cli/options/locking.ts b/packages/nuxt3/src/cli/options/locking.ts new file mode 100644 index 0000000000..28035510ae --- /dev/null +++ b/packages/nuxt3/src/cli/options/locking.ts @@ -0,0 +1,7 @@ +export default { + lock: { + type: 'boolean', + default: true, + description: 'Do not set a lock on the project when building' + } +} diff --git a/packages/nuxt3/src/cli/options/server.ts b/packages/nuxt3/src/cli/options/server.ts new file mode 100644 index 0000000000..36316b23e6 --- /dev/null +++ b/packages/nuxt3/src/cli/options/server.ts @@ -0,0 +1,29 @@ +import consola from 'consola' + +export default { + port: { + alias: 'p', + type: 'string', + description: 'Port number on which to start the application', + prepare (cmd, options, argv) { + if (argv.port) { + options.server.port = +argv.port + } + } + }, + hostname: { + alias: 'H', + type: 'string', + description: 'Hostname on which to start the application', + prepare (cmd, options, argv) { + if (argv.hostname === '') { + consola.fatal('Provided hostname argument has no value') + } + } + }, + 'unix-socket': { + alias: 'n', + type: 'string', + description: 'Path to a UNIX socket' + } +} diff --git a/packages/nuxt3/src/cli/run.ts b/packages/nuxt3/src/cli/run.ts new file mode 100644 index 0000000000..859c0479a7 --- /dev/null +++ b/packages/nuxt3/src/cli/run.ts @@ -0,0 +1,60 @@ +import fs from 'fs' +import execa from 'execa' +import { name as pkgName } from '../../package.json' +import NuxtCommand from './command' +import setup from './setup' +import getCommand from './commands' + +function packageExists (name) { + try { + require.resolve(name) + return true + } catch (e) { + return false + } +} + +export default async function run(_argv, hooks = {}) { + // Check for not installing both nuxt and nuxt-edge + const dupPkg = '@nuxt/' + (pkgName === '@nuxt/cli-edge' ? 'cli' : 'cli-edge') + if (packageExists(dupPkg)) { + throw new Error('Both `nuxt` and `nuxt-edge` dependencies are installed! This is unsupported, please choose one and remove the other one from dependencies.') + } + + // Read from process.argv + const argv = _argv ? Array.from(_argv) : process.argv.slice(2) + + // Check for internal command + let cmd = await getCommand(argv[0]) + + // Matching `nuxt` or `nuxt [dir]` or `nuxt -*` for `nuxt dev` shortcut + if (!cmd && (!argv[0] || argv[0][0] === '-' || (argv[0] !== 'static' && fs.existsSync(argv[0])))) { + argv.unshift('dev') + cmd = await getCommand('dev') + } + + // Check for dev + const dev = argv[0] === 'dev' + + // Setup env + setup({ dev }) + + // Try internal command + if (cmd) { + return NuxtCommand.run(cmd, argv.slice(1), hooks) + } + + // Try external command + try { + await execa(`nuxt-${argv[0]}`, argv.slice(1), { + stdout: process.stdout, + stderr: process.stderr, + stdin: process.stdin + }) + } catch (error) { + if (error.exitCode === 2) { + throw String(`Command not found: nuxt-${argv[0]}`) + } + throw String(`Failed to run command \`nuxt-${argv[0]}\`:\n${error}`) + } +} diff --git a/packages/nuxt3/src/cli/setup.ts b/packages/nuxt3/src/cli/setup.ts new file mode 100644 index 0000000000..1fba67e2ca --- /dev/null +++ b/packages/nuxt3/src/cli/setup.ts @@ -0,0 +1,38 @@ +import consola from 'consola' +import exit from 'exit' +import { fatalBox } from './utils/formatting' + +let _setup = false + +export default function setup ({ dev }) { + // Apply default NODE_ENV if not provided + if (!process.env.NODE_ENV) { + process.env.NODE_ENV = dev ? 'development' : 'production' + } + + if (_setup) { + return + } + _setup = true + + // Global error handler + /* istanbul ignore next */ + process.on('unhandledRejection', (err) => { + consola.error(err) + }) + + // Exit process on fatal errors + /* istanbul ignore next */ + consola.addReporter({ + log (logObj) { + if (logObj.type === 'fatal') { + const errorMessage = String(logObj.args[0]) + process.stderr.write(fatalBox(errorMessage)) + exit(1) + } + } + }) + + // Wrap all console logs with consola for better DX + consola.wrapConsole() +} diff --git a/packages/nuxt3/src/cli/utils/banner.ts b/packages/nuxt3/src/cli/utils/banner.ts new file mode 100644 index 0000000000..496c5d0faa --- /dev/null +++ b/packages/nuxt3/src/cli/utils/banner.ts @@ -0,0 +1,60 @@ +import consola from 'consola' +import env from 'std-env' +import chalk from 'chalk' +import { successBox } from './formatting' +import { getFormattedMemoryUsage } from './memory' + +export function showBanner (nuxt, showMemoryUsage = true) { + if (env.test) { + return + } + + if (env.minimalCLI) { + for (const listener of nuxt.server.listeners) { + consola.info('Listening on: ' + listener.url) + } + return + } + + const titleLines = [] + const messageLines = [] + + // Name and version + const { bannerColor, badgeMessages } = nuxt.options.cli + titleLines.push(`${chalk[bannerColor].bold('Nuxt.js')} @ ${nuxt.constructor.version || 'exotic'}\n`) + + const label = name => chalk.bold.cyan(`▸ ${name}:`) + + // Environment + const isDev = nuxt.options.dev + let _env = isDev ? 'development' : 'production' + if (process.env.NODE_ENV !== _env) { + _env += ` (${chalk.cyan(process.env.NODE_ENV)})` + } + titleLines.push(`${label('Environment')} ${_env}`) + + // Rendering + const isSSR = nuxt.options.render.ssr + const rendering = isSSR ? 'server-side' : 'client-side' + titleLines.push(`${label('Rendering')} ${rendering}`) + + // Target + const target = nuxt.options.target || 'server' + titleLines.push(`${label('Target')} ${target}`) + + if (showMemoryUsage) { + titleLines.push('\n' + getFormattedMemoryUsage()) + } + + // Listeners + for (const listener of nuxt.server.listeners) { + messageLines.push(chalk.bold('Listening: ') + chalk.underline.blue(listener.url)) + } + + // Add custom badge messages + if (badgeMessages.length) { + messageLines.push('', ...badgeMessages) + } + + process.stdout.write(successBox(messageLines.join('\n'), titleLines.join('\n'))) +} diff --git a/packages/nuxt3/src/cli/utils/config.ts b/packages/nuxt3/src/cli/utils/config.ts new file mode 100644 index 0000000000..3d4b6a37f8 --- /dev/null +++ b/packages/nuxt3/src/cli/utils/config.ts @@ -0,0 +1,33 @@ +import path from 'path' +import defaultsDeep from 'lodash/defaultsDeep' +import { loadNuxtConfig as _loadNuxtConfig, getDefaultNuxtConfig } from 'src/config' +import { MODES } from 'src/utils' + +export async function loadNuxtConfig (argv, configContext) { + const rootDir = path.resolve(argv._[0] || '.') + const configFile = argv['config-file'] + + // Load config + const options = await _loadNuxtConfig({ + rootDir, + configFile, + configContext, + envConfig: { + dotenv: argv.dotenv === 'false' ? false : argv.dotenv, + env: argv.processenv ? process.env : {} + } + }) + + // Nuxt Mode + options.mode = + (argv.spa && MODES.spa) || (argv.universal && MODES.universal) || options.mode + + // Server options + options.server = defaultsDeep({ + port: argv.port || undefined, + host: argv.hostname || undefined, + socket: argv['unix-socket'] || undefined + }, options.server || {}, getDefaultNuxtConfig().server) + + return options +} diff --git a/packages/nuxt3/src/cli/utils/constants.ts b/packages/nuxt3/src/cli/utils/constants.ts new file mode 100644 index 0000000000..4bd9089e8c --- /dev/null +++ b/packages/nuxt3/src/cli/utils/constants.ts @@ -0,0 +1,8 @@ +export const forceExitTimeout = 5 + +export const startSpaces = 2 +export const optionSpaces = 2 + +// 80% of terminal column width +// this is a fn because console width can have changed since startup +export const maxCharsPerLine = () => (process.stdout.columns || 100) * 80 / 100 diff --git a/packages/nuxt3/src/cli/utils/formatting.ts b/packages/nuxt3/src/cli/utils/formatting.ts new file mode 100644 index 0000000000..c085eab83c --- /dev/null +++ b/packages/nuxt3/src/cli/utils/formatting.ts @@ -0,0 +1,69 @@ +import wrapAnsi from 'wrap-ansi' +import chalk from 'chalk' +import boxen from 'boxen' +import { maxCharsPerLine } from './constants' + +export function indent (count, chr = ' ') { + return chr.repeat(count) +} + +export function indentLines (string, spaces, firstLineSpaces) { + const lines = Array.isArray(string) ? string : string.split('\n') + let s = '' + if (lines.length) { + const i0 = indent(firstLineSpaces === undefined ? spaces : firstLineSpaces) + s = i0 + lines.shift() + } + if (lines.length) { + const i = indent(spaces) + s += '\n' + lines.map(l => i + l).join('\n') + } + return s +} + +export function foldLines (string, spaces, firstLineSpaces, charsPerLine = maxCharsPerLine()) { + return indentLines(wrapAnsi(string, charsPerLine), spaces, firstLineSpaces) +} + +export function colorize (text) { + return text + .replace(/\[[^ ]+]/g, m => chalk.grey(m)) + .replace(/<[^ ]+>/g, m => chalk.green(m)) + .replace(/ (-[-\w,]+)/g, m => chalk.bold(m)) + .replace(/`([^`]+)`/g, (_, m) => chalk.bold.cyan(m)) +} + +export function box (message, title, options) { + return boxen([ + title || chalk.white('Nuxt Message'), + '', + chalk.white(foldLines(message, 0, 0, maxCharsPerLine())) + ].join('\n'), Object.assign({ + borderColor: 'white', + borderStyle: 'round', + padding: 1, + margin: 1 + }, options)) + '\n' +} + +export function successBox (message, title) { + return box(message, title || chalk.green('✔ Nuxt Success'), { + borderColor: 'green' + }) +} + +export function warningBox (message, title) { + return box(message, title || chalk.yellow('⚠ Nuxt Warning'), { + borderColor: 'yellow' + }) +} + +export function errorBox (message, title) { + return box(message, title || chalk.red('✖ Nuxt Error'), { + borderColor: 'red' + }) +} + +export function fatalBox (message, title) { + return errorBox(message, title || chalk.red('✖ Nuxt Fatal Error')) +} diff --git a/packages/nuxt3/src/cli/utils/index.ts b/packages/nuxt3/src/cli/utils/index.ts new file mode 100644 index 0000000000..e2b33019c8 --- /dev/null +++ b/packages/nuxt3/src/cli/utils/index.ts @@ -0,0 +1,64 @@ +import path from 'path' +import exit from 'exit' + +import { lock } from 'src/utils' +import chalk from 'chalk' +import env from 'std-env' +import { warningBox } from './formatting' + +export const eventsMapping = { + add: { icon: '+', color: 'green', action: 'Created' }, + change: { icon: env.windows ? '»' : '↻', color: 'blue', action: 'Updated' }, + unlink: { icon: '-', color: 'red', action: 'Removed' } +} + +export function formatPath (filePath) { + if (!filePath) { + return + } + return filePath.replace(process.cwd() + path.sep, '') +} + +/** + * Normalize string argument in command + * + * @export + * @param {String} argument + * @param {*} defaultValue + * @returns formatted argument + */ +export function normalizeArg (arg, defaultValue) { + switch (arg) { + case 'true': arg = true; break + case '': arg = true; break + case 'false': arg = false; break + case undefined: arg = defaultValue; break + } + return arg +} + +export function forceExit (cmdName, timeout) { + if (timeout !== false) { + const exitTimeout = setTimeout(() => { + const msg = `The command 'nuxt ${cmdName}' finished but did not exit after ${timeout}s +This is most likely not caused by a bug in Nuxt.js +Make sure to cleanup all timers and listeners you or your plugins/modules start. +Nuxt.js will now force exit + +${chalk.bold('DeprecationWarning: Starting with Nuxt version 3 this will be a fatal error')}` + + // TODO: Change this to a fatal error in v3 + process.stderr.write(warningBox(msg)) + exit(0) + }, timeout * 1000) + exitTimeout.unref() + } else { + exit(0) + } +} + +// An immediate export throws an error when mocking with jest +// TypeError: Cannot set property createLock of # which has only a getter +export function createLock (...args) { + return lock(...args) +} diff --git a/packages/nuxt3/src/cli/utils/memory.ts b/packages/nuxt3/src/cli/utils/memory.ts new file mode 100644 index 0000000000..3071e59f7a --- /dev/null +++ b/packages/nuxt3/src/cli/utils/memory.ts @@ -0,0 +1,18 @@ +import chalk from 'chalk' +import consola from 'consola' +import prettyBytes from 'pretty-bytes' + +export function getMemoryUsage () { + // https://nodejs.org/api/process.html#process_process_memoryusage + const { heapUsed, rss } = process.memoryUsage() + return { heap: heapUsed, rss } +} + +export function getFormattedMemoryUsage () { + const { heap, rss } = getMemoryUsage() + return `Memory usage: ${chalk.bold(prettyBytes(heap))} (RSS: ${prettyBytes(rss)})` +} + +export function showMemoryUsage () { + consola.info(getFormattedMemoryUsage()) +} diff --git a/packages/nuxt3/src/cli/utils/webpack.ts b/packages/nuxt3/src/cli/utils/webpack.ts new file mode 100644 index 0000000000..bf267a1211 --- /dev/null +++ b/packages/nuxt3/src/cli/utils/webpack.ts @@ -0,0 +1,9 @@ +import { loadNuxt } from 'src/core' +import { getBuilder } from 'src/builder' + +export async function getWebpackConfig(name = 'client', loadOptions = {}) { + const nuxt = await loadNuxt(loadOptions) + const builder = await getBuilder(nuxt) + const { bundleBuilder } = builder + return bundleBuilder.getWebpackConfig(name) +} diff --git a/packages/nuxt3/src/config/config/_app.ts b/packages/nuxt3/src/config/config/_app.ts new file mode 100644 index 0000000000..767fefc82b --- /dev/null +++ b/packages/nuxt3/src/config/config/_app.ts @@ -0,0 +1,76 @@ +export default () => ({ + vue: { + config: { + silent: undefined, // = !dev + performance: undefined // = dev + } + }, + + vueMeta: null, + + head: { + meta: [], + link: [], + style: [], + script: [] + }, + + fetch: { + server: true, + client: true + }, + + plugins: [], + + extendPlugins: null, + + css: [], + + layouts: {}, + + ErrorPage: null, + + loading: { + color: 'black', + failedColor: 'red', + height: '2px', + throttle: 200, + duration: 5000, + continuous: false, + rtl: false, + css: true + }, + + loadingIndicator: 'default', + + pageTransition: { + name: 'page', + mode: 'out-in', + appear: false, + appearClass: 'appear', + appearActiveClass: 'appear-active', + appearToClass: 'appear-to' + }, + + layoutTransition: { + name: 'layout', + mode: 'out-in' + }, + + features: { + store: true, + layouts: true, + meta: true, + middleware: true, + transitions: true, + deprecations: true, + validate: true, + asyncData: true, + fetch: true, + clientOnline: true, + clientPrefetch: true, + clientUseUrl: false, + componentAliases: true, + componentClientOnly: true + } +}) diff --git a/packages/nuxt3/src/config/config/_common.ts b/packages/nuxt3/src/config/config/_common.ts new file mode 100644 index 0000000000..d0b4d6cd4e --- /dev/null +++ b/packages/nuxt3/src/config/config/_common.ts @@ -0,0 +1,92 @@ +import capitalize from 'lodash/capitalize' +import env from 'std-env' +import { TARGETS, MODES } from 'src/utils' + +export default () => ({ + // Env + dev: Boolean(env.dev), + test: Boolean(env.test), + debug: undefined, // = dev + env: {}, + + createRequire: undefined, + + // Target + target: TARGETS.server, + + // Rendering + ssr: true, + + // TODO: remove in Nuxt 3 + // Mode + mode: MODES.universal, + modern: undefined, + + // Modules + modules: [], + buildModules: [], + _modules: [], + + globalName: undefined, + globals: { + id: globalName => `__${globalName}`, + nuxt: globalName => `$${globalName}`, + context: globalName => `__${globalName.toUpperCase()}__`, + pluginPrefix: globalName => globalName, + readyCallback: globalName => `on${capitalize(globalName)}Ready`, + loadedCallback: globalName => `_on${capitalize(globalName)}Loaded` + }, + + // Server + serverMiddleware: [], + + // Dirs and extensions + _nuxtConfigFile: undefined, + srcDir: undefined, + buildDir: '.nuxt', + modulesDir: [ + 'node_modules' + ], + dir: { + assets: 'assets', + app: 'app', + layouts: 'layouts', + middleware: 'middleware', + pages: 'pages', + static: 'static', + store: 'store' + }, + extensions: [], + styleExtensions: ['css', 'pcss', 'postcss', 'styl', 'stylus', 'scss', 'sass', 'less'], + alias: {}, + + // Ignores + ignoreOptions: undefined, + ignorePrefix: '-', + ignore: [ + '**/*.test.*', + '**/*.spec.*' + ], + + // Watch + watch: [], + watchers: { + rewatchOnRawEvents: undefined, + webpack: { + aggregateTimeout: 1000 + }, + chokidar: { + ignoreInitial: true + } + }, + + // Editor + editor: undefined, + + // Hooks + hooks: null, + + // runtimeConfig + privateRuntimeConfig: {}, + publicRuntimeConfig: {} +}) diff --git a/packages/nuxt3/src/config/config/build.ts b/packages/nuxt3/src/config/config/build.ts new file mode 100644 index 0000000000..6d38d5335f --- /dev/null +++ b/packages/nuxt3/src/config/config/build.ts @@ -0,0 +1,130 @@ +import env from 'std-env' + +export default () => ({ + quiet: Boolean(env.ci || env.test), + analyze: false, + profile: process.argv.includes('--profile'), + extractCSS: false, + cssSourceMap: undefined, + ssr: undefined, + parallel: false, + cache: false, + standalone: false, + publicPath: '/_nuxt/', + serverURLPolyfill: 'url', + filenames: { + // { isDev, isClient, isServer } + app: ({ isDev, isModern }) => isDev ? `[name]${isModern ? '.modern' : ''}.js` : `[name].[contenthash:7]${isModern ? '.modern' : ''}.js`, + chunk: ({ isDev, isModern }) => isDev ? `[name]${isModern ? '.modern' : ''}.js` : `[name].[contenthash:7]${isModern ? '.modern' : ''}.js`, + css: ({ isDev }) => isDev ? '[name].css' : '[name].[contenthash:7].css', + img: ({ isDev }) => isDev ? '[path][name].[ext]' : 'img/[name].[contenthash:7].[ext]', + font: ({ isDev }) => isDev ? '[path][name].[ext]' : 'fonts/[name].[contenthash:7].[ext]', + video: ({ isDev }) => isDev ? '[path][name].[ext]' : 'videos/[name].[contenthash:7].[ext]' + }, + loaders: { + file: {}, + fontUrl: { limit: 1000 }, + imgUrl: { limit: 1000 }, + pugPlain: {}, + vue: { + transformAssetUrls: { + video: 'src', + source: 'src', + object: 'src', + embed: 'src' + } + }, + css: {}, + cssModules: { + modules: { + localIdentName: '[local]_[hash:base64:5]' + } + }, + less: {}, + sass: { + sassOptions: { + indentedSyntax: true + } + }, + scss: {}, + stylus: {}, + vueStyle: {} + }, + styleResources: {}, + plugins: [], + terser: {}, + hardSource: false, + aggressiveCodeRemoval: false, + optimizeCSS: undefined, + optimization: { + runtimeChunk: 'single', + minimize: undefined, + minimizer: undefined, + splitChunks: { + chunks: 'all', + name: undefined, + cacheGroups: { + default: { + name: undefined + } + } + } + }, + splitChunks: { + layouts: false, + pages: true, + commons: true + }, + babel: { + configFile: false, + babelrc: false, + cacheDirectory: undefined + }, + transpile: [], // Name of NPM packages to be transpiled + postcss: { + preset: { + // https://cssdb.org/#staging-process + stage: 2 + } + }, + html: { + minify: { + collapseBooleanAttributes: true, + decodeEntities: true, + minifyCSS: true, + minifyJS: true, + processConditionalComments: true, + removeEmptyAttributes: true, + removeRedundantAttributes: true, + trimCustomFragments: true, + useShortDoctype: true + } + }, + + template: undefined, + templates: [], + + watch: [], + devMiddleware: {}, + hotMiddleware: {}, + + stats: { + excludeAssets: [ + /.map$/, + /index\..+\.html$/, + /vue-ssr-(client|modern)-manifest.json/ + ] + }, + friendlyErrors: true, + additionalExtensions: [], + warningIgnoreFilters: [], + + followSymlinks: false, + + loadingScreen: {}, + indicator: { + position: 'bottom-right', + backgroundColor: '#2E495E', + color: '#00C48D' + } +}) diff --git a/packages/nuxt3/src/config/config/cli.ts b/packages/nuxt3/src/config/config/cli.ts new file mode 100644 index 0000000000..88f6404b24 --- /dev/null +++ b/packages/nuxt3/src/config/config/cli.ts @@ -0,0 +1,4 @@ +export default () => ({ + badgeMessages: [], + bannerColor: 'green' +}) diff --git a/packages/nuxt3/src/config/config/generate.ts b/packages/nuxt3/src/config/config/generate.ts new file mode 100644 index 0000000000..5ac7fe11ce --- /dev/null +++ b/packages/nuxt3/src/config/config/generate.ts @@ -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}" + } +}) diff --git a/packages/nuxt3/src/config/config/index.ts b/packages/nuxt3/src/config/config/index.ts new file mode 100644 index 0000000000..2ebc198426 --- /dev/null +++ b/packages/nuxt3/src/config/config/index.ts @@ -0,0 +1,33 @@ + +import _app from './_app' +import _common from './_common' + +import build from './build' +import messages from './messages' +import modes from './modes' +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' + +export function getDefaultNuxtConfig (options = {}) { + if (!options.env) { + options.env = process.env + } + + return { + ..._app(), + ..._common(), + build: build(), + messages: messages(), + modes: modes(), + render: render(), + router: router(), + server: server(options), + cli: cli(), + generate: generate() + } +} diff --git a/packages/nuxt3/src/config/config/messages.ts b/packages/nuxt3/src/config/config/messages.ts new file mode 100644 index 0000000000..0c3694c2b5 --- /dev/null +++ b/packages/nuxt3/src/config/config/messages.ts @@ -0,0 +1,12 @@ +export default () => ({ + loading: 'Loading...', + error_404: 'This page could not be found', + server_error: 'Server error', + nuxtjs: 'Nuxt.js', + back_to_home: 'Back to the home page', + server_error_details: + 'An error occurred in the application and your page could not be served. If you are the application owner, check your logs for details.', + client_error: 'Error', + client_error_details: + 'An error occurred while rendering the page. Check developer tools console for details.' +}) diff --git a/packages/nuxt3/src/config/config/modes.ts b/packages/nuxt3/src/config/config/modes.ts new file mode 100644 index 0000000000..a1f071cdc7 --- /dev/null +++ b/packages/nuxt3/src/config/config/modes.ts @@ -0,0 +1,20 @@ +import { MODES } from 'src/utils' + +export default () => ({ + [MODES.universal]: { + build: { + ssr: true + }, + render: { + ssr: true + } + }, + [MODES.spa]: { + build: { + ssr: false + }, + render: { + ssr: false + } + } +}) diff --git a/packages/nuxt3/src/config/config/render.ts b/packages/nuxt3/src/config/config/render.ts new file mode 100644 index 0000000000..f478922cbf --- /dev/null +++ b/packages/nuxt3/src/config/config/render.ts @@ -0,0 +1,45 @@ +// TODO: Refactor @nuxt/server related options into `server.js` + +export default () => ({ + bundleRenderer: { + shouldPrefetch: () => false, + shouldPreload: (fileWithoutQuery, asType) => ['script', 'style'].includes(asType), + runInNewContext: undefined + }, + crossorigin: undefined, + resourceHints: true, + ssr: undefined, + ssrLog: undefined, + http2: { + push: false, + shouldPush: null, + pushAssets: null + }, + static: { + prefix: true + }, + compressor: { + threshold: 0 + }, + etag: { + weak: false + }, + csp: false, + dist: { + // Don't serve index.html template + index: false, + // 1 year in production + maxAge: '1y' + }, + // https://github.com/nuxt/serve-placeholder + fallback: { + dist: {}, + static: { + skipUnknown: true, + handlers: { + '.htm': false, + '.html': false + } + } + } +}) diff --git a/packages/nuxt3/src/config/config/router.ts b/packages/nuxt3/src/config/config/router.ts new file mode 100644 index 0000000000..834e892a66 --- /dev/null +++ b/packages/nuxt3/src/config/config/router.ts @@ -0,0 +1,18 @@ +export default () => ({ + mode: 'history', + base: '/', + routes: [], + routeNameSplitter: '-', + middleware: [], + linkActiveClass: 'nuxt-link-active', + linkExactActiveClass: 'nuxt-link-exact-active', + linkPrefetchedClass: false, + extendRoutes: null, + scrollBehavior: null, + parseQuery: false, + stringifyQuery: false, + fallback: false, + prefetchLinks: true, + prefetchPayloads: true, + trailingSlash: undefined +}) diff --git a/packages/nuxt3/src/config/config/server.ts b/packages/nuxt3/src/config/config/server.ts new file mode 100644 index 0000000000..8d45fee77f --- /dev/null +++ b/packages/nuxt3/src/config/config/server.ts @@ -0,0 +1,14 @@ +export default ({ env = {} } = {}) => ({ + https: false, + port: env.NUXT_PORT || + env.PORT || + env.npm_package_config_nuxt_port || + 3000, + host: env.NUXT_HOST || + env.HOST || + env.npm_package_config_nuxt_host || + 'localhost', + socket: env.UNIX_SOCKET || + env.npm_package_config_unix_socket, + timing: false +}) diff --git a/packages/nuxt3/src/config/index.ts b/packages/nuxt3/src/config/index.ts new file mode 100644 index 0000000000..ecb6359c3e --- /dev/null +++ b/packages/nuxt3/src/config/index.ts @@ -0,0 +1,3 @@ +export { defaultNuxtConfigFile, getDefaultNuxtConfig } from './config' +export { getNuxtConfig } from './options' +export { loadNuxtConfig } from './load' diff --git a/packages/nuxt3/src/config/load.ts b/packages/nuxt3/src/config/load.ts new file mode 100644 index 0000000000..b60bcff242 --- /dev/null +++ b/packages/nuxt3/src/config/load.ts @@ -0,0 +1,187 @@ +import path from 'path' +import fs from 'fs' +import defu from 'defu' +import consola from 'consola' +import dotenv from 'dotenv' +import { clearRequireCache, scanRequireTree } from 'src/utils' +import jiti from 'jiti' +import _createRequire from 'create-require' +import destr from 'destr' +import * as rc from 'rc9' +import { defaultNuxtConfigFile } from './config' + +const isJest = typeof jest !== 'undefined' + +export async function loadNuxtConfig ({ + rootDir = '.', + envConfig = {}, + configFile = defaultNuxtConfigFile, + configContext = {}, + configOverrides = {}, + createRequire = module => isJest ? _createRequire(module.filename) : jiti(module.filename) +} = {}) { + rootDir = path.resolve(rootDir) + + let options = {} + + try { + configFile = require.resolve(path.resolve(rootDir, configFile)) + } catch (e) { + if (e.code !== 'MODULE_NOT_FOUND') { + throw (e) + } else if (configFile !== defaultNuxtConfigFile) { + consola.fatal('Config file not found: ' + configFile) + } + // Skip configFile if cannot resolve + configFile = undefined + } + + // Load env + envConfig = { + dotenv: '.env', + env: process.env, + expand: true, + ...envConfig + } + + const env = loadEnv(envConfig, rootDir) + + // Fill process.env so it is accessible in nuxt.config + for (const key in env) { + if (!key.startsWith('_') && envConfig.env[key] === undefined) { + envConfig.env[key] = env[key] + } + } + + if (configFile) { + // Clear cache + clearRequireCache(configFile) + const _require = createRequire(module) + options = _require(configFile) || {} + if (options.default) { + options = options.default + } + + if (typeof options === 'function') { + try { + options = await options(configContext) + if (options.default) { + options = options.default + } + } catch (error) { + consola.error(error) + consola.fatal('Error while fetching async configuration') + } + } + + // Don't mutate options export + options = { ...options } + + // Keep _nuxtConfigFile for watching + options._nuxtConfigFile = configFile + + // Keep all related files for watching + options._nuxtConfigFiles = Array.from(scanRequireTree(configFile)) + if (!options._nuxtConfigFiles.includes(configFile)) { + options._nuxtConfigFiles.unshift(configFile) + } + } + + if (typeof options.rootDir !== 'string') { + options.rootDir = rootDir + } + + // Load Combine configs + // Priority: configOverrides > nuxtConfig > .nuxtrc > .nuxtrc (global) + options = defu( + configOverrides, + options, + rc.read({ name: '.nuxtrc', dir: options.rootDir }), + rc.readUser('.nuxtrc') + ) + + // Load env to options._env + options._env = env + options._envConfig = envConfig + if (configContext) { configContext.env = env } + + // Expand and interpolate runtimeConfig from _env + if (envConfig.expand) { + for (const c of ['publicRuntimeConfig', 'privateRuntimeConfig']) { + if (options[c]) { + if (typeof options[c] === 'function') { + options[c] = options[c](env) + } + expand(options[c], env, destr) + } + } + } + + return options +} + +function loadEnv (envConfig, rootDir = process.cwd()) { + const env = Object.create(null) + + // Read dotenv + if (envConfig.dotenv) { + envConfig.dotenv = path.resolve(rootDir, envConfig.dotenv) + if (fs.existsSync(envConfig.dotenv)) { + const parsed = dotenv.parse(fs.readFileSync(envConfig.dotenv, 'utf-8')) + Object.assign(env, parsed) + } + } + + // Apply process.env + if (!envConfig.env._applied) { + Object.assign(env, envConfig.env) + envConfig.env._applied = true + } + + // Interpolate env + if (envConfig.expand) { + expand(env) + } + + return env +} + +// Based on https://github.com/motdotla/dotenv-expand +function expand (target, source = {}, parse = v => v) { + function getValue (key) { + // Source value 'wins' over target value + return source[key] !== undefined ? source[key] : target[key] + } + + function interpolate (value) { + if (typeof value !== 'string') { + return value + } + const matches = value.match(/(.?\${?(?:[a-zA-Z0-9_:]+)?}?)/g) || [] + return parse(matches.reduce((newValue, match) => { + const parts = /(.?)\${?([a-zA-Z0-9_:]+)?}?/g.exec(match) + const prefix = parts[1] + + let value, replacePart + + if (prefix === '\\') { + replacePart = parts[0] + value = replacePart.replace('\\$', '$') + } else { + const key = parts[2] + replacePart = parts[0].substring(prefix.length) + + value = getValue(key) + + // Resolve recursive interpolations + value = interpolate(value) + } + + return newValue.replace(replacePart, value) + }, value)) + } + + for (const key in target) { + target[key] = interpolate(getValue(key)) + } +} diff --git a/packages/nuxt3/src/config/options.ts b/packages/nuxt3/src/config/options.ts new file mode 100644 index 0000000000..e48018f0cb --- /dev/null +++ b/packages/nuxt3/src/config/options.ts @@ -0,0 +1,494 @@ +import path from 'path' +import fs from 'fs' +import defaultsDeep from 'lodash/defaultsDeep' +import defu from 'defu' +import pick from 'lodash/pick' +import uniq from 'lodash/uniq' +import consola from 'consola' +import destr from 'destr' +import { TARGETS, MODES, guardDir, isNonEmptyString, isPureObject, isUrl, getMainModule, urlJoin, getPKG } from 'src/utils' +import { defaultNuxtConfigFile, getDefaultNuxtConfig } from './config' + +export function getNuxtConfig (_options) { + // Prevent duplicate calls + if (_options.__normalized__) { + return _options + } + + // Clone options to prevent unwanted side-effects + const options = Object.assign({}, _options) + options.__normalized__ = true + + // Normalize options + if (options.loading === true) { + delete options.loading + } + + if ( + options.router && + options.router.middleware && + !Array.isArray(options.router.middleware) + ) { + options.router.middleware = [options.router.middleware] + } + + if (options.router && typeof options.router.base === 'string') { + options._routerBaseSpecified = true + } + + // TODO: Remove for Nuxt 3 + // router.scrollBehavior -> app/router.scrollBehavior.js + if (options.router && typeof options.router.scrollBehavior !== 'undefined') { + consola.warn('`router.scrollBehavior` property is deprecated in favor of using `~/app/router.scrollBehavior.js` file, learn more: https://nuxtjs.org/api/configuration-router#scrollbehavior') + } + + // TODO: Remove for Nuxt 3 + // transition -> pageTransition + if (typeof options.transition !== 'undefined') { + consola.warn('`transition` property is deprecated in favor of `pageTransition` and will be removed in Nuxt 3') + options.pageTransition = options.transition + delete options.transition + } + + if (typeof options.pageTransition === 'string') { + options.pageTransition = { name: options.pageTransition } + } + + if (typeof options.layoutTransition === 'string') { + options.layoutTransition = { name: options.layoutTransition } + } + + if (typeof options.extensions === 'string') { + options.extensions = [options.extensions] + } + + options.globalName = (isNonEmptyString(options.globalName) && /^[a-zA-Z]+$/.test(options.globalName)) + ? options.globalName.toLowerCase() + // use `` for preventing replacing to nuxt-edge + : 'nuxt' + + // Resolve rootDir + options.rootDir = isNonEmptyString(options.rootDir) ? path.resolve(options.rootDir) : process.cwd() + + // Apply defaults by ${buildDir}/dist/build.config.js + // TODO: Unsafe operation. + // const buildDir = options.buildDir || defaults.buildDir + // const buildConfig = resolve(options.rootDir, buildDir, 'build.config.js') + // if (existsSync(buildConfig)) { + // defaultsDeep(options, require(buildConfig)) + // } + + // Apply defaults + const nuxtConfig = getDefaultNuxtConfig() + + nuxtConfig.build._publicPath = nuxtConfig.build.publicPath + + // Fall back to default if publicPath is falsy + if (options.build && !options.build.publicPath) { + options.build.publicPath = undefined + } + + 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 += '/' + } + + // Alias export to generate + // TODO: switch to export by default for nuxt3 + if (options.export) { + options.generate = defu(options.export, options.generate) + } + exports.export = options.generate + + // Check srcDir and generate.dir existence + const hasSrcDir = isNonEmptyString(options.srcDir) + const hasGenerateDir = isNonEmptyString(options.generate.dir) + + // Resolve srcDir + options.srcDir = hasSrcDir + ? path.resolve(options.rootDir, options.srcDir) + : options.rootDir + + // Resolve buildDir + options.buildDir = path.resolve(options.rootDir, options.buildDir) + + // Aliases + const { rootDir, srcDir, dir: { assets: assetsDir, static: staticDir } } = options + options.alias = { + '~~': rootDir, + '@@': rootDir, + '~': srcDir, + '@': srcDir, + [assetsDir]: path.join(srcDir, assetsDir), + [staticDir]: path.join(srcDir, staticDir), + ...options.alias + } + + // Default value for _nuxtConfigFile + if (!options._nuxtConfigFile) { + options._nuxtConfigFile = path.resolve(options.rootDir, `${defaultNuxtConfigFile}.js`) + } + + if (!options._nuxtConfigFiles) { + options._nuxtConfigFiles = [ + options._nuxtConfigFile + ] + } + + // Watch for config file changes + options.watch.push(...options._nuxtConfigFiles) + + // Protect rootDir against buildDir + guardDir(options, 'rootDir', 'buildDir') + + if (hasGenerateDir) { + // Resolve generate.dir + options.generate.dir = path.resolve(options.rootDir, options.generate.dir) + + // Protect rootDir against buildDir + guardDir(options, 'rootDir', 'generate.dir') + } + + if (hasSrcDir) { + // Protect srcDir against buildDir + guardDir(options, 'srcDir', 'buildDir') + + if (hasGenerateDir) { + // Protect srcDir against generate.dir + guardDir(options, 'srcDir', 'generate.dir') + } + } + + // Populate modulesDir + options.modulesDir = uniq( + getMainModule().paths.concat( + [].concat(options.modulesDir).map(dir => path.resolve(options.rootDir, dir)) + ) + ) + + const mandatoryExtensions = ['js', 'mjs'] + + options.extensions = mandatoryExtensions + .filter(ext => !options.extensions.includes(ext)) + .concat(options.extensions) + + // If app.html is defined, set the template path to the user template + if (options.appTemplatePath === undefined) { + options.appTemplatePath = path.resolve(options.buildDir, 'views/app.template.html') + if (fs.existsSync(path.join(options.srcDir, 'app.html'))) { + options.appTemplatePath = path.join(options.srcDir, 'app.html') + } + } else { + options.appTemplatePath = path.resolve(options.srcDir, options.appTemplatePath) + } + + options.build.publicPath = options.build.publicPath.replace(/([^/])$/, '$1/') + options.build._publicPath = options.build._publicPath.replace(/([^/])$/, '$1/') + + // Ignore publicPath on dev + if (options.dev && isUrl(options.build.publicPath)) { + options.build.publicPath = options.build._publicPath + } + + // If store defined, update store options to true unless explicitly disabled + if ( + options.store !== false && + fs.existsSync(path.join(options.srcDir, options.dir.store)) && + fs.readdirSync(path.join(options.srcDir, options.dir.store)) + .find(filename => filename !== 'README.md' && filename[0] !== '.') + ) { + options.store = true + } + + // SPA loadingIndicator + if (options.loadingIndicator) { + // Normalize loadingIndicator + if (!isPureObject(options.loadingIndicator)) { + options.loadingIndicator = { name: options.loadingIndicator } + } + + // Apply defaults + options.loadingIndicator = Object.assign( + { + name: 'default', + color: (options.loading && options.loading.color) || '#D3D3D3', + color2: '#F5F5F5', + background: (options.manifest && options.manifest.theme_color) || 'white', + dev: options.dev, + loading: options.messages.loading + }, + options.loadingIndicator + ) + } + + // Debug errors + if (options.debug === undefined) { + options.debug = options.dev + } + + // Validate that etag.hash is a function, if not unset it + if (options.render.etag) { + const { hash } = options.render.etag + if (hash) { + const isFn = typeof hash === 'function' + if (!isFn) { + options.render.etag.hash = undefined + + if (options.dev) { + consola.warn(`render.etag.hash should be a function, received ${typeof hash} instead`) + } + } + } + } + + // Apply default hash to CSP option + if (options.render.csp) { + options.render.csp = defu(options.render.csp, { + hashAlgorithm: 'sha256', + allowedSources: undefined, + policies: undefined, + addMeta: Boolean(options.target === TARGETS.static), + unsafeInlineCompatibility: false, + reportOnly: options.debug + }) + + // TODO: Remove this if statement in Nuxt 3, we will stop supporting this typo (more on: https://github.com/nuxt/nuxt.js/pull/6583) + if (options.render.csp.unsafeInlineCompatiblity) { + consola.warn('Using `unsafeInlineCompatiblity` is deprecated and will be removed in Nuxt 3. Use `unsafeInlineCompatibility` instead.') + options.render.csp.unsafeInlineCompatibility = options.render.csp.unsafeInlineCompatiblity + delete options.render.csp.unsafeInlineCompatiblity + } + } + + // cssSourceMap + if (options.build.cssSourceMap === undefined) { + options.build.cssSourceMap = options.dev + } + + const babelConfig = options.build.babel + // babel cacheDirectory + if (babelConfig.cacheDirectory === undefined) { + babelConfig.cacheDirectory = options.dev + } + + // TODO: remove this warn in Nuxt 3 + if (Array.isArray(babelConfig.presets)) { + const warnPreset = (presetName) => { + const oldPreset = '@nuxtjs/babel-preset-app' + const newPreset = '@nuxt/babel-preset-app' + if (presetName.includes(oldPreset)) { + presetName = presetName.replace(oldPreset, newPreset) + consola.warn('@nuxtjs/babel-preset-app has been deprecated, please use @nuxt/babel-preset-app.') + } + return presetName + } + babelConfig.presets = babelConfig.presets.map((preset) => { + const hasOptions = Array.isArray(preset) + if (hasOptions) { + preset[0] = warnPreset(preset[0]) + } else if (typeof preset === 'string') { + preset = warnPreset(preset) + } + return preset + }) + } + + // Vue config + const vueConfig = options.vue.config + + if (vueConfig.silent === undefined) { + vueConfig.silent = !options.dev + } + if (vueConfig.performance === undefined) { + vueConfig.performance = options.dev + } + + // merge custom env with variables + const eligibleEnvVariables = pick(process.env, Object.keys(process.env).filter(k => k.startsWith('NUXT_ENV_'))) + Object.assign(options.env, eligibleEnvVariables) + + // Normalize ignore + options.ignore = options.ignore ? [].concat(options.ignore) : [] + + // Append ignorePrefix glob to ignore + if (typeof options.ignorePrefix === 'string') { + options.ignore.push(`**/${options.ignorePrefix}*.*`) + } + + // Compression middleware legacy + if (options.render.gzip) { + consola.warn('render.gzip is deprecated and will be removed in a future version! Please switch to render.compressor') + options.render.compressor = options.render.gzip + delete options.render.gzip + } + + // If no server-side rendering, add appear true transition + if (options.render.ssr === false && options.pageTransition) { + options.pageTransition.appear = true + } + + options.render.ssrLog = options.dev + ? options.render.ssrLog === undefined || options.render.ssrLog + : false + + // We assume the SPA fallback path is 404.html (for GitHub Pages, Surge, etc.) + if (options.generate.fallback === true) { + options.generate.fallback = '404.html' + } + + if (options.build.stats === 'none' || options.build.quiet === true) { + options.build.stats = false + } + + // Vendor backward compatibility with nuxt 1.x + if (typeof options.build.vendor !== 'undefined') { + delete options.build.vendor + consola.warn('vendor has been deprecated due to webpack4 optimization') + } + + // Disable CSS extraction due to incompatibility with thread-loader + if (options.build.extractCSS && options.build.parallel) { + options.build.parallel = false + consola.warn('extractCSS cannot work with parallel build due to limited work pool in thread-loader') + } + + // build.extractCSS.allChunks has no effect + if (typeof options.build.extractCSS.allChunks !== 'undefined') { + consola.warn('build.extractCSS.allChunks has no effect from v2.0.0. Please use build.optimization.splitChunks settings instead.') + } + + // devModules has been renamed to buildModules + if (typeof options.devModules !== 'undefined') { + consola.warn('`devModules` has been renamed to `buildModules` and will be removed in Nuxt 3.') + options.buildModules.push(...options.devModules) + delete options.devModules + } + + // Enable minimize for production builds + if (options.build.optimization.minimize === undefined) { + options.build.optimization.minimize = !options.dev + } + + // Enable optimizeCSS only when extractCSS is enabled + if (options.build.optimizeCSS === undefined) { + options.build.optimizeCSS = options.build.extractCSS ? {} : false + } + + const { loaders } = options.build + const vueLoader = loaders.vue + if (vueLoader.productionMode === undefined) { + vueLoader.productionMode = !options.dev + } + const styleLoaders = [ + 'css', 'cssModules', 'less', + 'sass', 'scss', 'stylus', 'vueStyle' + ] + for (const name of styleLoaders) { + const loader = loaders[name] + if (loader && loader.sourceMap === undefined) { + loader.sourceMap = Boolean(options.build.cssSourceMap) + } + } + + options.build.transpile = [].concat(options.build.transpile || []) + + if (options.build.quiet === true) { + consola.level = 0 + } + + // Use runInNewContext for dev mode by default + const { bundleRenderer } = options.render + if (typeof bundleRenderer.runInNewContext === 'undefined') { + bundleRenderer.runInNewContext = options.dev + } + + // TODO: Remove this if statement in Nuxt 3 + if (options.build.crossorigin) { + consola.warn('Using `build.crossorigin` is deprecated and will be removed in Nuxt 3. Please use `render.crossorigin` instead.') + options.render.crossorigin = options.build.crossorigin + delete options.build.crossorigin + } + + const { timing } = options.server + if (timing) { + options.server.timing = { total: true, ...timing } + } + + if (isPureObject(options.serverMiddleware)) { + options.serverMiddleware = Object.entries(options.serverMiddleware) + .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) + } + + // createRequire factory + if (options.createRequire === undefined) { + const createRequire = require('create-require') + options.createRequire = module => createRequire(module.filename) + } + + // ----- Builtin modules ----- + + // Loading screen + // Force disable for production and programmatic users + if (!options.dev || !options._cli || !getPKG('@nuxt/loading-screen')) { + options.build.loadingScreen = false + } + if (options.build.loadingScreen) { + options._modules.push(['@nuxt/loading-screen', options.build.loadingScreen]) + } else { + // When loadingScreen is disabled we should also disable build indicator + options.build.indicator = false + } + + // Components Module + // TODO: Webpack5 support + // if (!options._start && getPKG('@nuxt/components')) { + // options._modules.push('@nuxt/components') + // } + + // Nuxt Telemetry + if ( + options.telemetry !== false && + !options.test && + !destr(process.env.NUXT_TELEMETRY_DISABLED) && + getPKG('@nuxt/telemetry') + ) { + options._modules.push('@nuxt/telemetry') + } + + return options +} diff --git a/packages/nuxt3/src/core/index.ts b/packages/nuxt3/src/core/index.ts new file mode 100644 index 0000000000..16f86764cf --- /dev/null +++ b/packages/nuxt3/src/core/index.ts @@ -0,0 +1,5 @@ +export { default as Module } from './module' +export { default as Nuxt } from './nuxt' +export { default as Resolver } from './resolver' +export { loadNuxtConfig } from 'src/config' +export { loadNuxt } from './load' diff --git a/packages/nuxt3/src/core/load.ts b/packages/nuxt3/src/core/load.ts new file mode 100644 index 0000000000..690ddc81d8 --- /dev/null +++ b/packages/nuxt3/src/core/load.ts @@ -0,0 +1,40 @@ +import { loadNuxtConfig } from '../config' +import Nuxt from './nuxt' + +const OVERRIDES = { + dry: { dev: false, server: false }, + dev: { dev: true, _build: true }, + build: { dev: false, server: false, _build: true }, + start: { dev: false, _start: true } +} + +export async function loadNuxt (loadOptions) { + // Normalize loadOptions + if (typeof loadOptions === 'string') { + loadOptions = { for: loadOptions } + } + const { ready = true } = loadOptions + const _for = loadOptions.for || 'dry' + + // Get overrides + const override = OVERRIDES[_for] + + // Unsupported purpose + if (!override) { + throw new Error('Unsupported for: ' + _for) + } + + // Load Config + const config = await loadNuxtConfig(loadOptions) + + // Apply config overrides + Object.assign(config, override) + + // Initiate Nuxt + const nuxt = new Nuxt(config) + if (ready) { + await nuxt.ready() + } + + return nuxt +} diff --git a/packages/nuxt3/src/core/module.ts b/packages/nuxt3/src/core/module.ts new file mode 100644 index 0000000000..27c0fad5fa --- /dev/null +++ b/packages/nuxt3/src/core/module.ts @@ -0,0 +1,215 @@ +import path from 'path' +import fs from 'fs' +import hash from 'hash-sum' +import consola from 'consola' + +import { chainFn, sequence } from 'src/utils' + +export default class ModuleContainer { + constructor (nuxt) { + this.nuxt = nuxt + this.options = nuxt.options + this.requiredModules = {} + + // Self bind to allow destructre from container + for (const method of Object.getOwnPropertyNames(ModuleContainer.prototype)) { + if (typeof this[method] === 'function') { + this[method] = this[method].bind(this) + } + } + } + + async ready () { + // Call before hook + await this.nuxt.callHook('modules:before', this, this.options.modules) + + if (this.options.buildModules && !this.options._start) { + // Load every devModule in sequence + await sequence(this.options.buildModules, this.addModule) + } + + // Load every module in sequence + await sequence(this.options.modules, this.addModule) + + // Load ah-hoc modules last + await sequence(this.options._modules, this.addModule) + + // Call done hook + await this.nuxt.callHook('modules:done', this) + } + + addVendor () { + consola.warn('addVendor has been deprecated due to webpack4 optimization') + } + + addTemplate (template) { + if (!template) { + throw new Error('Invalid template: ' + JSON.stringify(template)) + } + + // Validate & parse source + const src = template.src || template + const srcPath = path.parse(src) + + if (typeof src !== 'string' || !fs.existsSync(src)) { + throw new Error('Template src not found: ' + src) + } + + // 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, + dst, + options: template.options + } + + this.options.build.templates.push(templateObj) + + return templateObj + } + + addPlugin (template) { + const { dst } = this.addTemplate(template) + + // Add to nuxt plugins + this.options.plugins.unshift({ + src: path.join(this.options.buildDir, dst), + // TODO: remove deprecated option in Nuxt 3 + ssr: template.ssr, + mode: template.mode + }) + } + + addLayout (template, name) { + const { dst, src } = this.addTemplate(template) + const layoutName = name || path.parse(src).name + const layout = this.options.layouts[layoutName] + + if (layout) { + consola.warn(`Duplicate layout registration, "${layoutName}" has been registered as "${layout}"`) + } + + // Add to nuxt layouts + this.options.layouts[layoutName] = `./${dst}` + + // If error layout, set ErrorPage + if (name === 'error') { + this.addErrorLayout(dst) + } + } + + addErrorLayout (dst) { + const relativeBuildDir = path.relative(this.options.rootDir, this.options.buildDir) + this.options.ErrorPage = `~/${relativeBuildDir}/${dst}` + } + + addServerMiddleware (middleware) { + this.options.serverMiddleware.push(middleware) + } + + extendBuild (fn) { + this.options.build.extend = chainFn(this.options.build.extend, fn) + } + + extendRoutes (fn) { + this.options.router.extendRoutes = chainFn( + this.options.router.extendRoutes, + fn + ) + } + + requireModule (moduleOpts) { + return this.addModule(moduleOpts) + } + + async addModule (moduleOpts) { + let src + let options + let handler + + // Type 1: String or Function + if (typeof moduleOpts === 'string' || typeof moduleOpts === 'function') { + src = moduleOpts + } else if (Array.isArray(moduleOpts)) { + // Type 2: Babel style array + [src, options] = moduleOpts + } else if (typeof moduleOpts === 'object') { + // Type 3: Pure object + ({ src, options, handler } = moduleOpts) + } + + // Define handler if src is a function + if (typeof src === 'function') { + handler = src + } + + // Prevent adding buildModules-listed entries in production + if (this.options.buildModules.includes(handler) && this.options._start) { + return + } + + // Resolve handler + if (!handler) { + try { + handler = this.nuxt.resolver.requireModule(src, { useESM: true }) + } catch (error) { + if (error.code !== 'MODULE_NOT_FOUND') { + throw error + } + + // Hint only if entrypoint is not found and src is not local alias or path + if (error.message.includes(src) && !/^[~.]|^@\//.test(src)) { + let message = 'Module `{name}` not found.' + + if (this.options.buildModules.includes(src)) { + message += ' Please ensure `{name}` is in `devDependencies` and installed. HINT: During build step, for npm/yarn, `NODE_ENV=production` or `--production` should NOT be used.'.replace('{name}', src) + } else if (this.options.modules.includes(src)) { + message += ' Please ensure `{name}` is in `dependencies` and installed.' + } + + message = message.replace(/{name}/g, src) + + consola.warn(message) + } + + if (this.options._cli) { + throw error + } else { + // TODO: Remove in next major version + consola.warn('Silently ignoring module as programatic usage detected.') + return + } + } + } + + // Validate handler + if (typeof handler !== 'function') { + throw new TypeError('Module should export a function: ' + src) + } + + // Ensure module is required once + const metaKey = handler.meta && handler.meta.name + const key = metaKey || src + if (typeof key === 'string') { + if (this.requiredModules[key]) { + if (!metaKey) { + // TODO: Skip with nuxt3 + consola.warn('Modules should be only specified once:', key) + } else { + return + } + } + this.requiredModules[key] = { src, options, handler } + } + + // Default module options to empty object + if (options === undefined) { + options = {} + } + const result = await handler.call(this, options) + return result + } +} diff --git a/packages/nuxt3/src/core/nuxt.ts b/packages/nuxt3/src/core/nuxt.ts new file mode 100644 index 0000000000..a3bb7bc832 --- /dev/null +++ b/packages/nuxt3/src/core/nuxt.ts @@ -0,0 +1,115 @@ + +import isPlainObject from 'lodash/isPlainObject' +import consola from 'consola' +import Hookable from 'hable' + +import { defineAlias } from 'src/utils' +import { getNuxtConfig } from 'src/config' +import { Server } from 'src/server' + +import { version } from '../../package.json' + +import ModuleContainer from './module' +import Resolver from './resolver' + +export default class Nuxt extends Hookable { + constructor (options = {}) { + super(consola) + + // Assign options and apply defaults + this.options = getNuxtConfig(options) + + // Create instance of core components + this.resolver = new Resolver(this) + this.moduleContainer = new ModuleContainer(this) + + // Deprecated hooks + this.deprecateHooks({ + // #3294 - 7514db73b25c23b8c14ebdafbb4e129ac282aabd + 'render:context': { + to: '_render:context', + message: '`render:context(nuxt)` is deprecated, Please use `vue-renderer:ssr:context(context)`' + }, + // #3773 + 'render:routeContext': { + to: '_render:context', + message: '`render:routeContext(nuxt)` is deprecated, Please use `vue-renderer:ssr:context(context)`' + }, + showReady: 'webpack:done' + }) + + // Add Legacy aliases + defineAlias(this, this.resolver, ['resolveAlias', 'resolvePath']) + this.showReady = () => { this.callHook('webpack:done') } + + // Init server + if (this.options.server !== false) { + this._initServer() + } + + // Call ready + if (this.options._ready !== false) { + this.ready().catch((err) => { + consola.fatal(err) + }) + } + } + + static get version () { + return `v${version}` + (global.__NUXT_DEV__ ? '-development' : '') + } + + ready () { + if (!this._ready) { + this._ready = this._init() + } + return this._ready + } + + async _init () { + if (this._initCalled) { + return this + } + this._initCalled = true + + // Add hooks + if (isPlainObject(this.options.hooks)) { + this.addHooks(this.options.hooks) + } else if (typeof this.options.hooks === 'function') { + this.options.hooks(this.hook) + } + + // Await for modules + await this.moduleContainer.ready() + + // Await for server to be ready + if (this.server) { + await this.server.ready() + } + + // Call ready hook + await this.callHook('ready', this) + + return this + } + + _initServer () { + if (this.server) { + return + } + this.server = new Server(this) + this.renderer = this.server + this.render = this.server.app + defineAlias(this, this.server, ['renderRoute', 'renderAndGetWindow', 'listen']) + } + + async close (callback) { + await this.callHook('close', this) + + if (typeof callback === 'function') { + await callback() + } + + this.clearHooks() + } +} diff --git a/packages/nuxt3/src/core/resolver.ts b/packages/nuxt3/src/core/resolver.ts new file mode 100644 index 0000000000..20b762b257 --- /dev/null +++ b/packages/nuxt3/src/core/resolver.ts @@ -0,0 +1,182 @@ +import { resolve, join } from 'path' +import fs from 'fs-extra' +import consola from 'consola' + +import { + startsWithRootAlias, + startsWithSrcAlias, + isExternalDependency, + clearRequireCache +} from 'src/utils' + +export default class Resolver { + constructor (nuxt) { + this.nuxt = nuxt + this.options = this.nuxt.options + + // Binds + this.resolvePath = this.resolvePath.bind(this) + this.resolveAlias = this.resolveAlias.bind(this) + this.resolveModule = this.resolveModule.bind(this) + this.requireModule = this.requireModule.bind(this) + + const { createRequire } = this.options + this._require = createRequire ? createRequire(module) : module.require + + this._resolve = require.resolve + } + + resolveModule (path) { + try { + return this._resolve(path, { + paths: this.options.modulesDir + }) + } catch (error) { + if (error.code !== 'MODULE_NOT_FOUND') { + // TODO: remove after https://github.com/facebook/jest/pull/8487 released + if (process.env.NODE_ENV === 'test' && error.message.startsWith('Cannot resolve module')) { + return + } + throw error + } + } + } + + resolveAlias (path) { + if (startsWithRootAlias(path)) { + return join(this.options.rootDir, path.substr(2)) + } + + if (startsWithSrcAlias(path)) { + return join(this.options.srcDir, path.substr(1)) + } + + return resolve(this.options.srcDir, path) + } + + resolvePath (path, { alias, isAlias = alias, module, isModule = module, isStyle } = {}) { + // TODO: Remove in Nuxt 3 + if (alias) { + consola.warn('Using alias is deprecated and will be removed in Nuxt 3. Use `isAlias` instead.') + } + if (module) { + consola.warn('Using module is deprecated and will be removed in Nuxt 3. Use `isModule` instead.') + } + + // Fast return in case of path exists + if (fs.existsSync(path)) { + return path + } + + let resolvedPath + + // Try to resolve it as a regular module + if (isModule !== false) { + resolvedPath = this.resolveModule(path) + } + + // Try to resolve alias + if (!resolvedPath && isAlias !== false) { + resolvedPath = this.resolveAlias(path) + } + + // Use path for resolvedPath + if (!resolvedPath) { + resolvedPath = path + } + + let isDirectory + + // Check if resolvedPath exits and is not a directory + if (fs.existsSync(resolvedPath)) { + isDirectory = fs.lstatSync(resolvedPath).isDirectory() + + if (!isDirectory) { + return resolvedPath + } + } + + const extensions = isStyle ? this.options.styleExtensions : this.options.extensions + + // Check if any resolvedPath.[ext] or resolvedPath/index.[ext] exists + for (const ext of extensions) { + if (!isDirectory && fs.existsSync(resolvedPath + '.' + ext)) { + return resolvedPath + '.' + ext + } + + const resolvedPathwithIndex = join(resolvedPath, 'index.' + ext) + if (isDirectory && fs.existsSync(resolvedPathwithIndex)) { + return resolvedPathwithIndex + } + } + + // If there's no index.[ext] we just return the directory path + if (isDirectory) { + return resolvedPath + } + + // Give up + throw new Error(`Cannot resolve "${path}" from "${resolvedPath}"`) + } + + requireModule (path, { esm, useESM = esm, alias, isAlias = alias, intropDefault, interopDefault = intropDefault } = {}) { + let resolvedPath = path + let requiredModule + + // TODO: Remove in Nuxt 3 + if (intropDefault) { + consola.warn('Using intropDefault is deprecated and will be removed in Nuxt 3. Use `interopDefault` instead.') + } + if (alias) { + consola.warn('Using alias is deprecated and will be removed in Nuxt 3. Use `isAlias` instead.') + } + if (esm) { + consola.warn('Using esm is deprecated and will be removed in Nuxt 3. Use `useESM` instead.') + } + + let lastError + + // Try to resolve path + try { + resolvedPath = this.resolvePath(path, { isAlias }) + } catch (e) { + lastError = e + } + + const isExternal = isExternalDependency(resolvedPath) + + // in dev mode make sure to clear the require cache so after + // a dev server restart any changed file is reloaded + if (this.options.dev && !isExternal) { + clearRequireCache(resolvedPath) + } + + // By default use esm only for js,mjs files outside of node_modules + if (useESM === undefined) { + useESM = !isExternal && /.(js|mjs)$/.test(resolvedPath) + } + + // Try to require + try { + if (useESM) { + requiredModule = this._require(resolvedPath) + } else { + requiredModule = require(resolvedPath) + } + } catch (e) { + lastError = e + } + + // Interop default + if (interopDefault !== false && requiredModule && requiredModule.default) { + requiredModule = requiredModule.default + } + + // Throw error if failed to require + if (requiredModule === undefined && lastError) { + throw lastError + } + + return requiredModule + } +} diff --git a/packages/nuxt3/src/generator/generator.ts b/packages/nuxt3/src/generator/generator.ts new file mode 100644 index 0000000000..fdfc1b3f84 --- /dev/null +++ b/packages/nuxt3/src/generator/generator.ts @@ -0,0 +1,412 @@ +import path from 'path' +import chalk from 'chalk' +import consola from 'consola' +import fsExtra from 'fs-extra' +import defu from 'defu' +import htmlMinifier from 'html-minifier' +import { parse } from 'node-html-parser' + +import { isFullStatic, flatRoutes, isString, isUrl, promisifyRoute, waitFor, TARGETS } from 'src/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) + this.srcBuiltPath = path.resolve(this.options.buildDir, 'dist', 'client') + this.distPath = this.options.generate.dir + this.distNuxtPath = path.join( + this.distPath, + isUrl(this.options.build.publicPath) ? '' : this.options.build.publicPath + ) + + // Shared payload + this._payload = null + this.setPayload = (payload) => { + this._payload = defu(payload, this._payload) + } + } + + async generate ({ build = true, init = true } = {}) { + consola.debug('Initializing generator...') + await this.initiate({ build, init }) + + // 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() + + // Done hook + await this.nuxt.callHook('generate:done', this, errors) + await this.nuxt.callHook('export:done', this, { errors }) + + return { errors } + } + + async initiate ({ build = true, init = true } = {}) { + // Wait for nuxt be ready + await this.nuxt.ready() + + // Call before hook + await this.nuxt.callHook('generate:before', this, this.options.generate) + await this.nuxt.callHook('export:before', this) + + if (build) { + // Add flag to set process.static + this.builder.forGenerate() + + // Start build process + await this.builder.build() + this.isFullStatic = isFullStatic(this.options) + } else { + const hasBuilt = await fsExtra.exists(path.resolve(this.options.buildDir, 'dist', 'server', 'client.manifest.json')) + if (!hasBuilt) { + const fullStaticArgs = isFullStatic(this.options) ? ' --target static' : '' + throw new Error( + `No build files found in ${this.srcBuiltPath}.\nPlease run \`nuxt build${fullStaticArgs}\` before calling \`nuxt export\`` + ) + } + const config = this.getBuildConfig() + if (!config || (config.target !== TARGETS.static && !this.options._legacyGenerate)) { + 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 + if (init) { + await this.initDist() + } + } + + async initRoutes (...args) { + // Resolve config.generate.routes promises before generating the routes + let generateRoutes = [] + if (this.options.router.mode !== 'hash') { + try { + generateRoutes = await promisifyRoute( + this.options.generate.routes || [], + ...args + ) + } catch (e) { + consola.error('Could not resolve routes') + throw e // eslint-disable-line no-unreachable + } + } + let routes = [] + // Generate only index.html for router.mode = 'hash' or client-side apps + if (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 + await this.nuxt.callHook('generate:extendRoutes', routes) + await this.nuxt.callHook('export:extendRoutes', { routes }) + + return routes + } + + shouldGenerateRoute (route) { + return this.options.generate.exclude.every((regex) => { + if (typeof regex === 'string') { + return regex !== route + } + return !regex.test(route) + }) + } + + getBuildConfig () { + try { + return require(path.join(this.options.buildDir, 'nuxt/config.json')) + } catch (err) { + return null + } + } + + getAppRoutes () { + return require(path.join(this.options.buildDir, 'routes.json')) + } + + async generateRoutes (routes) { + const errors = [] + + this.routes = [] + this.generatedRoutes = new Set() + + routes.forEach(({ route, ...props }) => { + route = decodeURI(route) + this.routes.push({ route, ...props }) + // Add routes to the tracked generated routes (for crawler) + this.generatedRoutes.add(route) + }) + + // Start generate process + while (this.routes.length) { + let n = 0 + await Promise.all( + this.routes + .splice(0, this.options.generate.concurrency) + .map(async ({ route, payload }) => { + await waitFor(n++ * this.options.generate.interval) + await this.generateRoute({ route, payload, errors }) + }) + ) + } + + // Improve string representation for errors + // TODO: Use consola for more consistency + errors.toString = () => this._formatErrors(errors) + + return errors + } + + _formatErrors (errors) { + return errors + .map(({ type, route, error }) => { + const isHandled = type === 'handled' + const color = isHandled ? 'yellow' : 'red' + + let line = chalk[color](` ${route}\n\n`) + + if (isHandled) { + line += chalk.grey(JSON.stringify(error, undefined, 2) + '\n') + } else { + line += chalk.grey(error.stack || error.message || `${error}`) + } + + return line + }) + .join('\n') + } + + async afterGenerate () { + const { fallback } = this.options.generate + + // Disable SPA fallback if value isn't a non-empty string + if (typeof fallback !== 'string' || !fallback) { + return + } + + const fallbackPath = path.join(this.distPath, fallback) + + // Prevent conflicts + if (await fsExtra.exists(fallbackPath)) { + consola.warn(`SPA fallback was configured, but the configured path (${fallbackPath}) already exists.`) + return + } + + // Render and write the SPA template to the fallback path + let { html } = await this.nuxt.server.renderRoute('/', { + spa: true, + staticAssetsBase: this.staticAssetsBase + }) + + try { + html = this.minifyHtml(html) + } catch (error) { + consola.warn('HTML minification failed for SPA fallback') + } + + await fsExtra.writeFile(fallbackPath, html, 'utf8') + consola.success('Client-side fallback created: `' + fallback + '`') + } + + async initDist () { + // Clean destination folder + await fsExtra.emptyDir(this.distPath) + + consola.info(`Generating output directory: ${path.basename(this.distPath)}/`) + await this.nuxt.callHook('generate:distRemoved', this) + await this.nuxt.callHook('export: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') + fsExtra.writeFile(nojekyllPath, '') + + await this.nuxt.callHook('generate:distCopied', this) + await this.nuxt.callHook('export:distCopied', this) + } + + decorateWithPayloads (routes, generateRoutes) { + const routeMap = {} + // Fill routeMap for known routes + routes.forEach((route) => { + routeMap[route] = { route, payload: null } + }) + // Fill routeMap with given generate.routes + generateRoutes.forEach((route) => { + // route is either a string or like { route : '/my_route/1', payload: {} } + const path = isString(route) ? route : route.route + routeMap[path] = { + route: path, + payload: route.payload || null + } + }) + return Object.values(routeMap) + } + + async generateRoute ({ route, payload = {}, errors = [] }) { + let html + const pageErrors = [] + + const setPayload = (_payload) => { + payload = defu(_payload, payload) + } + + // Apply shared payload + if (this._payload) { + payload = defu(payload, this._payload) + } + + await this.nuxt.callHook('generate:route', { route, setPayload }) + await this.nuxt.callHook('export:route', { route, setPayload }) + + try { + 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) { + const possibleTrailingSlash = this.options.router.trailingSlash ? '/' : '' + parse(html).querySelectorAll('a').map((el) => { + const sanitizedHref = (el.getAttribute('href') || '') + .replace(this.options.router.base, '/') + .replace(/\/+$/, '') + .split('?')[0] + .split('#')[0] + .trim() + + const route = decodeURI(sanitizedHref + possibleTrailingSlash) + + if (route.startsWith('/') && !path.extname(route) && this.shouldGenerateRoute(route) && !this.generatedRoutes.has(route)) { + this.generatedRoutes.add(route) + this.routes.push({ route }) + } + }) + } + + // 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 }) + } + } catch (err) { + pageErrors.push({ type: 'unhandled', route, error: err }) + errors.push(...pageErrors) + + await this.nuxt.callHook('generate:routeFailed', { route, errors: pageErrors }) + await this.nuxt.callHook('export:routeFailed', { route, errors: pageErrors }) + consola.error(this._formatErrors(pageErrors)) + + return false + } + + try { + html = this.minifyHtml(html) + } catch (err) { + const minifyErr = new Error( + `HTML minification failed. Make sure the route generates valid HTML. Failed HTML:\n ${html}` + ) + pageErrors.push({ type: 'unhandled', route, error: minifyErr }) + } + + let fileName + + if (this.options.generate.subFolders) { + fileName = path.join(route, path.sep, 'index.html') // /about -> /about/index.html + fileName = fileName === '/404/index.html' ? '/404.html' : fileName // /404 -> /404.html + } else { + const normalizedRoute = route.replace(/\/$/, '') + fileName = route.length > 1 ? path.join(path.sep, normalizedRoute + '.html') : path.join(path.sep, 'index.html') + } + + // Call hook to let user update the path & html + const page = { route, path: fileName, html, exclude: false } + await this.nuxt.callHook('generate:page', page) + await this.nuxt.callHook('export:page', { page, errors: pageErrors }) + + if (page.exclude) { + return false + } + page.path = path.join(this.distPath, page.path) + + // Make sure the sub folders are created + await fsExtra.mkdirp(path.dirname(page.path)) + await fsExtra.writeFile(page.path, page.html, 'utf8') + + await this.nuxt.callHook('generate:routeCreated', { route, path: page.path, errors: pageErrors }) + await this.nuxt.callHook('export:routeCreated', { route, path: page.path, errors: pageErrors }) + + if (pageErrors.length) { + consola.error(`Error generating route "${route}": ${pageErrors.map(e => e.error.message).join(', ')}`) + errors.push(...pageErrors) + } else { + consola.success(`Generated route "${route}"`) + } + + return true + } + + minifyHtml (html) { + let minificationOptions = this.options.build.html.minify + + // Legacy: Override minification options with generate.minify if present + // TODO: Remove in Nuxt version 3 + if (typeof this.options.generate.minify !== 'undefined') { + minificationOptions = this.options.generate.minify + consola.warn('generate.minify has been deprecated and will be removed in the next major version.' + + ' Use build.html.minify instead!') + } + + if (!minificationOptions) { + return html + } + + return htmlMinifier.minify(html, minificationOptions) + } +} diff --git a/packages/nuxt3/src/generator/index.ts b/packages/nuxt3/src/generator/index.ts new file mode 100644 index 0000000000..6e420c7e35 --- /dev/null +++ b/packages/nuxt3/src/generator/index.ts @@ -0,0 +1,6 @@ +import Generator from './generator' +export { default as Generator } from './generator' + +export function getGenerator (nuxt) { + return new Generator(nuxt) +} diff --git a/packages/nuxt3/src/server/context.ts b/packages/nuxt3/src/server/context.ts new file mode 100644 index 0000000000..99dc07c321 --- /dev/null +++ b/packages/nuxt3/src/server/context.ts @@ -0,0 +1,8 @@ +export default class ServerContext { + constructor (server) { + this.nuxt = server.nuxt + this.globals = server.globals + this.options = server.options + this.resources = server.resources + } +} diff --git a/packages/nuxt3/src/server/index.ts b/packages/nuxt3/src/server/index.ts new file mode 100644 index 0000000000..854a01a63b --- /dev/null +++ b/packages/nuxt3/src/server/index.ts @@ -0,0 +1,2 @@ +export { default as Server } from './server' +export { default as Listener } from './listener' diff --git a/packages/nuxt3/src/server/jsdom.ts b/packages/nuxt3/src/server/jsdom.ts new file mode 100644 index 0000000000..c84b24ad55 --- /dev/null +++ b/packages/nuxt3/src/server/jsdom.ts @@ -0,0 +1,72 @@ +import consola from 'consola' +import { timeout } from 'src/utils' + +export default async function renderAndGetWindow ( + url = 'http://localhost:3000', + jsdomOpts = {}, + { + loadedCallback, + loadingTimeout = 2000, + globals + } = {} +) { + const jsdom = await import('jsdom') + .then(m => m.default || m) + .catch((e) => { + consola.error(` + jsdom is not installed. Please install jsdom with: + $ yarn add --dev jsdom + OR + $ npm install --dev jsdom + `) + throw e + }) + + const options = Object.assign({ + // Load subresources (https://github.com/tmpvar/jsdom#loading-subresources) + resources: 'usable', + runScripts: 'dangerously', + virtualConsole: true, + beforeParse (window) { + // Mock window.scrollTo + window.scrollTo = () => {} + } + }, jsdomOpts) + + const jsdomErrHandler = (err) => { + throw err + } + + if (options.virtualConsole) { + if (options.virtualConsole === true) { + options.virtualConsole = new jsdom.VirtualConsole().sendTo(consola) + } + // Throw error when window creation failed + options.virtualConsole.on('jsdomError', jsdomErrHandler) + } + + 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(`id="${globals.id}"`) + + if (!nuxtExists) { + const error = new Error('Could not load the nuxt app') + error.body = window.document.body.innerHTML + window.close() + throw error + } + + // Used by Nuxt.js to say when the components are loaded and the app ready + await timeout(new Promise((resolve) => { + window[loadedCallback] = () => resolve(window) + }), loadingTimeout, `Components loading in renderAndGetWindow was not completed in ${loadingTimeout / 1000}s`) + + if (options.virtualConsole) { + // After window initialized successfully + options.virtualConsole.removeListener('jsdomError', jsdomErrHandler) + } + + // Send back window object + return window +} diff --git a/packages/nuxt3/src/server/listener.ts b/packages/nuxt3/src/server/listener.ts new file mode 100644 index 0000000000..125a566f6c --- /dev/null +++ b/packages/nuxt3/src/server/listener.ts @@ -0,0 +1,119 @@ +import http from 'http' +import https from 'https' +import enableDestroy from 'server-destroy' +import ip from 'ip' +import consola from 'consola' +import pify from 'pify' + +let RANDOM_PORT = '0' + +export default class Listener { + constructor ({ port, host, socket, https, app, dev, baseURL }) { + // Options + this.port = port + this.host = host + this.socket = socket + this.https = https + this.app = app + this.dev = dev + this.baseURL = baseURL + + // After listen + this.listening = false + this._server = null + this.server = null + this.address = null + this.url = null + } + + async close () { + // Destroy server by forcing every connection to be closed + if (this.server && this.server.listening) { + await this.server.destroy() + consola.debug('server closed') + } + + // Delete references + this.listening = false + this._server = null + this.server = null + this.address = null + this.url = null + } + + computeURL () { + const address = this.server.address() + if (!this.socket) { + switch (address.address) { + case '127.0.0.1': this.host = 'localhost'; break + case '0.0.0.0': this.host = ip.address(); break + } + this.port = address.port + this.url = `http${this.https ? 's' : ''}://${this.host}:${this.port}${this.baseURL}` + return + } + this.url = `unix+http://${address}` + } + + async listen () { + // Prevent multi calls + if (this.listening) { + return + } + + // Initialize underlying http(s) server + const protocol = this.https ? https : http + const protocolOpts = this.https ? [this.https] : [] + this._server = protocol.createServer.apply(protocol, protocolOpts.concat(this.app)) + + // Call server.listen + // Prepare listenArgs + const listenArgs = this.socket ? { path: this.socket } : { host: this.host, port: this.port } + listenArgs.exclusive = false + + // Call server.listen + try { + this.server = await new Promise((resolve, reject) => { + this._server.on('error', error => reject(error)) + const s = this._server.listen(listenArgs, error => error ? reject(error) : resolve(s)) + }) + } catch (error) { + return this.serverErrorHandler(error) + } + + // Enable destroy support + enableDestroy(this.server) + pify(this.server.destroy) + + // Compute listen URL + this.computeURL() + + // Set this.listening to true + this.listening = true + } + + async serverErrorHandler (error) { + // Detect if port is not available + const addressInUse = error.code === 'EADDRINUSE' + + // Use better error message + if (addressInUse) { + const address = this.socket || `${this.host}:${this.port}` + error.message = `Address \`${address}\` is already in use.` + + // Listen to a random port on dev as a fallback + if (this.dev && !this.socket && this.port !== RANDOM_PORT) { + consola.warn(error.message) + consola.info('Trying a random port...') + this.port = RANDOM_PORT + await this.close() + await this.listen() + RANDOM_PORT = this.port + return + } + } + + // Throw error + throw error + } +} diff --git a/packages/nuxt3/src/server/middleware/error.ts b/packages/nuxt3/src/server/middleware/error.ts new file mode 100644 index 0000000000..9603281b84 --- /dev/null +++ b/packages/nuxt3/src/server/middleware/error.ts @@ -0,0 +1,130 @@ +import path from 'path' +import fs from 'fs-extra' +import consola from 'consola' + +import Youch from '@nuxtjs/youch' + +export default ({ resources, options }) => async function errorMiddleware (_error, req, res, next) { + // Normalize error + const error = normalizeError(_error, options) + + const sendResponse = (content, type = 'text/html') => { + // Set Headers + res.statusCode = error.statusCode + res.statusMessage = 'RuntimeError' + res.setHeader('Content-Type', type + '; charset=utf-8') + res.setHeader('Content-Length', Buffer.byteLength(content)) + res.setHeader('Cache-Control', 'no-cache, no-store, max-age=0, must-revalidate') + + // Error headers + if (error.headers) { + for (const name in error.headers) { + res.setHeader(name, error.headers[name]) + } + } + + // Send Response + res.end(content, 'utf-8') + } + + // Check if request accepts JSON + const hasReqHeader = (header, includes) => + req.headers[header] && req.headers[header].toLowerCase().includes(includes) + const isJson = + hasReqHeader('accept', 'application/json') || + hasReqHeader('user-agent', 'curl/') + + // Use basic errors when debug mode is disabled + if (!options.debug) { + // We hide actual errors from end users, so show them on server logs + if (error.statusCode !== 404) { + consola.error(error) + } + + // Json format is compatible with Youch json responses + const json = { + status: error.statusCode, + message: error.message, + name: error.name + } + if (isJson) { + sendResponse(JSON.stringify(json, undefined, 2), 'text/json') + return + } + const html = resources.errorTemplate(json) + sendResponse(html) + return + } + + // Show stack trace + const youch = new Youch( + error, + req, + readSource, + options.router.base, + true + ) + if (isJson) { + const json = await youch.toJSON() + sendResponse(JSON.stringify(json, undefined, 2), 'text/json') + return + } + + const html = await youch.toHTML() + sendResponse(html) +} + +const sanitizeName = name => name ? name.replace('webpack:///', '').split('?')[0] : null + +const normalizeError = (_error, { srcDir, rootDir, buildDir }) => { + if (typeof _error === 'string') { + _error = { message: _error } + } else if (!_error) { + _error = { message: '' } + } + + const error = new Error() + error.message = _error.message + error.name = _error.name + error.statusCode = _error.statusCode || 500 + error.headers = _error.headers + + const searchPath = [ + srcDir, + rootDir, + path.join(buildDir, 'dist', 'server'), + buildDir, + process.cwd() + ] + + const findInPaths = (fileName) => { + for (const dir of searchPath) { + const fullPath = path.resolve(dir, fileName) + if (fs.existsSync(fullPath)) { + return fullPath + } + } + return fileName + } + + error.stack = (_error.stack || '') + .split('\n') + .map((line) => { + const match = line.match(/\(([^)]+)\)|([^\s]+\.[^\s]+):/) + if (!match) { + return line + } + const src = match[1] || match[2] || '' + return line.replace(src, findInPaths(sanitizeName(src))) + }) + .join('\n') + + return error +} + +async function readSource (frame) { + if (fs.existsSync(frame.fileName)) { + frame.fullPath = frame.fileName // Youch BW compat + frame.contents = await fs.readFile(frame.fileName, 'utf-8') + } +} diff --git a/packages/nuxt3/src/server/middleware/nuxt.ts b/packages/nuxt3/src/server/middleware/nuxt.ts new file mode 100644 index 0000000000..3e60be3166 --- /dev/null +++ b/packages/nuxt3/src/server/middleware/nuxt.ts @@ -0,0 +1,155 @@ +import generateETag from 'etag' +import fresh from 'fresh' +import consola from 'consola' + +import { getContext, TARGETS } from 'src/utils' + +export default ({ options, nuxt, renderRoute, resources }) => async function nuxtMiddleware (req, res, next) { + // Get context + const context = getContext(req, res) + + try { + const url = decodeURI(req.url) + res.statusCode = 200 + const result = await renderRoute(url, context) + + // If result is falsy, call renderLoading + if (!result) { + await nuxt.callHook('server:nuxt:renderLoading', req, res) + return + } + + await nuxt.callHook('render:route', url, result, context) + const { + html, + cspScriptSrcHashes, + error, + redirected, + preloadFiles + } = result + + if (redirected && context.target !== TARGETS.static) { + await nuxt.callHook('render:routeDone', url, result, context) + return html + } + if (error) { + res.statusCode = context.nuxt.error.statusCode || 500 + } + + // Add ETag header + if (!error && options.render.etag) { + const { hash } = options.render.etag + const etag = hash ? hash(html, options.render.etag) : generateETag(html, options.render.etag) + if (fresh(req.headers, { etag })) { + res.statusCode = 304 + await nuxt.callHook('render:beforeResponse', url, result, context) + res.end() + await nuxt.callHook('render:routeDone', url, result, context) + return + } + res.setHeader('ETag', etag) + } + + // HTTP2 push headers for preload assets + if (!error && options.render.http2.push) { + // Parse resourceHints to extract HTTP.2 prefetch/push headers + // https://w3c.github.io/preload/#server-push-http-2 + const { shouldPush, pushAssets } = options.render.http2 + const { publicPath } = resources.clientManifest + + const links = pushAssets + ? pushAssets(req, res, publicPath, preloadFiles) + : defaultPushAssets(preloadFiles, shouldPush, publicPath, options) + + // Pass with single Link header + // https://blog.cloudflare.com/http-2-server-push-with-multiple-assets-per-link-header + // https://www.w3.org/Protocols/9707-link-header.html + if (links.length > 0) { + res.setHeader('Link', links.join(', ')) + } + } + + if (options.render.csp && cspScriptSrcHashes) { + const { allowedSources, policies } = options.render.csp + const isReportOnly = !!options.render.csp.reportOnly + const cspHeader = isReportOnly ? 'Content-Security-Policy-Report-Only' : 'Content-Security-Policy' + + res.setHeader(cspHeader, getCspString({ cspScriptSrcHashes, allowedSources, policies, isDev: options.dev, isReportOnly })) + } + + // Send response + res.setHeader('Content-Type', 'text/html; charset=utf-8') + res.setHeader('Accept-Ranges', 'none') // #3870 + res.setHeader('Content-Length', Buffer.byteLength(html)) + await nuxt.callHook('render:beforeResponse', url, result, context) + res.end(html, 'utf8') + await nuxt.callHook('render:routeDone', url, result, context) + return html + } catch (err) { + if (context && context.redirected) { + consola.error(err) + return err + } + + if (err.name === 'URIError') { + err.statusCode = 400 + } + next(err) + } +} + +const defaultPushAssets = (preloadFiles, shouldPush, publicPath, options) => { + if (shouldPush && options.dev) { + consola.warn('http2.shouldPush is deprecated. Use http2.pushAssets function') + } + + const links = [] + preloadFiles.forEach(({ file, asType, fileWithoutQuery, modern }) => { + // By default, we only preload scripts or css + if (!shouldPush && asType !== 'script' && asType !== 'style') { + return + } + + // User wants to explicitly control what to preload + if (shouldPush && !shouldPush(fileWithoutQuery, asType)) { + return + } + + const { crossorigin } = options.render + const cors = `${crossorigin ? ` crossorigin=${crossorigin};` : ''}` + // `modulepreload` rel attribute only supports script-like `as` value + // https://html.spec.whatwg.org/multipage/links.html#link-type-modulepreload + const rel = modern && asType === 'script' ? 'modulepreload' : 'preload' + + links.push(`<${publicPath}${file}>; rel=${rel};${cors} as=${asType}`) + }) + return links +} + +const getCspString = ({ cspScriptSrcHashes, allowedSources, policies, isDev, isReportOnly }) => { + const joinedHashes = cspScriptSrcHashes.join(' ') + const baseCspStr = `script-src 'self'${isDev ? ' \'unsafe-eval\'' : ''} ${joinedHashes}` + const policyObjectAvailable = typeof policies === 'object' && policies !== null && !Array.isArray(policies) + + if (Array.isArray(allowedSources) && allowedSources.length) { + return isReportOnly && policyObjectAvailable && !!policies['report-uri'] ? `${baseCspStr} ${allowedSources.join(' ')}; report-uri ${policies['report-uri']};` : `${baseCspStr} ${allowedSources.join(' ')}` + } + + if (policyObjectAvailable) { + const transformedPolicyObject = transformPolicyObject(policies, cspScriptSrcHashes) + return Object.entries(transformedPolicyObject).map(([k, v]) => `${k} ${Array.isArray(v) ? v.join(' ') : v}`).join('; ') + } + + return baseCspStr +} + +const transformPolicyObject = (policies, cspScriptSrcHashes) => { + const userHasDefinedScriptSrc = policies['script-src'] && Array.isArray(policies['script-src']) + + const additionalPolicies = userHasDefinedScriptSrc ? policies['script-src'] : [] + + // Self is always needed for inline-scripts, so add it, no matter if the user specified script-src himself. + const hashAndPolicyList = cspScriptSrcHashes.concat('\'self\'', additionalPolicies) + + return { ...policies, 'script-src': hashAndPolicyList } +} diff --git a/packages/nuxt3/src/server/middleware/timing.ts b/packages/nuxt3/src/server/middleware/timing.ts new file mode 100644 index 0000000000..c0434b651c --- /dev/null +++ b/packages/nuxt3/src/server/middleware/timing.ts @@ -0,0 +1,56 @@ +import consola from 'consola' +import onHeaders from 'on-headers' +import { Timer } from 'src/utils' + +export default options => (req, res, next) => { + if (res.timing) { + consola.warn('server-timing is already registered.') + } + res.timing = new ServerTiming() + + if (options && options.total) { + res.timing.start('total', 'Nuxt Server Time') + } + + onHeaders(res, () => { + res.timing.end('total') + + if (res.timing.headers.length > 0) { + res.setHeader( + 'Server-Timing', + [] + .concat(res.getHeader('Server-Timing') || []) + .concat(res.timing.headers) + .join(', ') + ) + } + res.timing.clear() + }) + + next() +} + +class ServerTiming extends Timer { + constructor (...args) { + super(...args) + this.headers = [] + } + + end (...args) { + const time = super.end(...args) + if (time) { + this.headers.push(this.formatHeader(time)) + } + return time + } + + clear () { + super.clear() + this.headers.length = 0 + } + + formatHeader (time) { + const desc = time.description ? `;desc="${time.description}"` : '' + return `${time.name};dur=${time.duration}${desc}` + } +} diff --git a/packages/nuxt3/src/server/package.ts b/packages/nuxt3/src/server/package.ts new file mode 100644 index 0000000000..2f78f772ee --- /dev/null +++ b/packages/nuxt3/src/server/package.ts @@ -0,0 +1,6 @@ +export default { + build: true, + rollup: { + externals: ['jsdom'] + } +} diff --git a/packages/nuxt3/src/server/server.ts b/packages/nuxt3/src/server/server.ts new file mode 100644 index 0000000000..dea665936d --- /dev/null +++ b/packages/nuxt3/src/server/server.ts @@ -0,0 +1,388 @@ +import path from 'path' +import consola from 'consola' +import launchMiddleware from 'launch-editor-middleware' +import serveStatic from 'serve-static' +import servePlaceholder from 'serve-placeholder' +import connect from 'connect' +import { determineGlobals, isUrl } from 'src/utils' +import { VueRenderer } from 'src/vue-renderer' + +import ServerContext from './context' +import renderAndGetWindow from './jsdom' +import nuxtMiddleware from './middleware/nuxt' +import errorMiddleware from './middleware/error' +import Listener from './listener' +import createTimingMiddleware from './middleware/timing' + +export default class Server { + constructor (nuxt) { + this.nuxt = nuxt + this.options = nuxt.options + + this.globals = determineGlobals(nuxt.options.globalName, nuxt.options.globals) + + this.publicPath = isUrl(this.options.build.publicPath) + ? this.options.build._publicPath + : this.options.build.publicPath + + // Runtime shared resources + this.resources = {} + + // Will be set after listen + this.listeners = [] + + // Create new connect instance + this.app = connect() + + // Close hook + this.nuxt.hook('close', () => this.close()) + + // devMiddleware placeholder + if (this.options.dev) { + this.nuxt.hook('server:devMiddleware', (devMiddleware) => { + this.devMiddleware = devMiddleware + }) + } + } + + async ready () { + if (this._readyCalled) { + return this + } + this._readyCalled = true + + await this.nuxt.callHook('render:before', this, this.options.render) + + // Initialize vue-renderer + this.serverContext = new ServerContext(this) + this.renderer = new VueRenderer(this.serverContext) + await this.renderer.ready() + + // Setup nuxt middleware + await this.setupMiddleware() + + // Call done hook + await this.nuxt.callHook('render:done', this) + + return this + } + + async setupMiddleware () { + // Apply setupMiddleware from modules first + await this.nuxt.callHook('render:setupMiddleware', this.app) + + // Compression middleware for production + if (!this.options.dev) { + const { compressor } = this.options.render + if (typeof compressor === 'object') { + // If only setting for `compression` are provided, require the module and insert + const compression = this.nuxt.resolver.requireModule('compression') + this.useMiddleware(compression(compressor)) + } else if (compressor) { + // Else, require own compression middleware if compressor is actually truthy + this.useMiddleware(compressor) + } + } + + if (this.options.server.timing) { + this.useMiddleware(createTimingMiddleware(this.options.server.timing)) + } + + // For serving static/ files to / + const staticMiddleware = serveStatic( + path.resolve(this.options.srcDir, this.options.dir.static), + this.options.render.static + ) + staticMiddleware.prefix = this.options.render.static.prefix + this.useMiddleware(staticMiddleware) + + // Serve .nuxt/dist/client files only for production + // For dev they will be served with devMiddleware + if (!this.options.dev) { + const distDir = path.resolve(this.options.buildDir, 'dist', 'client') + this.useMiddleware({ + path: this.publicPath, + handler: serveStatic( + distDir, + this.options.render.dist + ) + }) + } + + // Dev middleware + if (this.options.dev) { + this.useMiddleware((req, res, next) => { + if (!this.devMiddleware) { + return next() + } + this.devMiddleware(req, res, next) + }) + + // open in editor for debug mode only + if (this.options.debug) { + this.useMiddleware({ + path: '__open-in-editor', + handler: launchMiddleware(this.options.editor) + }) + } + } + + // Add user provided middleware + for (const m of this.options.serverMiddleware) { + this.useMiddleware(m) + } + + // Graceful 404 error handler + const { fallback } = this.options.render + if (fallback) { + // Dist files + if (fallback.dist) { + this.useMiddleware({ + path: this.publicPath, + handler: servePlaceholder(fallback.dist) + }) + } + + // Other paths + if (fallback.static) { + this.useMiddleware({ + path: '/', + handler: servePlaceholder(fallback.static) + }) + } + } + + // Finally use nuxtMiddleware + this.useMiddleware(nuxtMiddleware({ + options: this.options, + nuxt: this.nuxt, + renderRoute: this.renderRoute.bind(this), + resources: this.resources + })) + + // Apply errorMiddleware from modules first + await this.nuxt.callHook('render:errorMiddleware', this.app) + + // Error middleware for errors that occurred in middleware that declared above + this.useMiddleware(errorMiddleware({ + resources: this.resources, + options: this.options + })) + } + + _normalizeMiddleware (middleware) { + // Normalize plain function + if (typeof middleware === 'function') { + middleware = { handle: middleware } + } + + // If a plain string provided as path to middleware + if (typeof middleware === 'string') { + middleware = this._requireMiddleware(middleware) + } + + // Normalize handler to handle (backward compatibility) + if (middleware.handler && !middleware.handle) { + middleware.handle = middleware.handler + delete middleware.handler + } + + // Normalize path to route (backward compatibility) + if (middleware.path && !middleware.route) { + middleware.route = middleware.path + delete middleware.path + } + + // If handle is a string pointing to path + if (typeof middleware.handle === 'string') { + Object.assign(middleware, this._requireMiddleware(middleware.handle)) + } + + // No handle + if (!middleware.handle) { + middleware.handle = (req, res, next) => { + next(new Error('ServerMiddleware should expose a handle: ' + middleware.entry)) + } + } + + // Prefix on handle (proxy-module) + if (middleware.handle.prefix !== undefined && middleware.prefix === undefined) { + middleware.prefix = middleware.handle.prefix + } + + // sub-app (express) + if (typeof middleware.handle.handle === 'function') { + const server = middleware.handle + middleware.handle = server.handle.bind(server) + } + + return middleware + } + + _requireMiddleware (entry) { + // Resolve entry + entry = this.nuxt.resolver.resolvePath(entry) + + // Require middleware + let middleware + try { + middleware = this.nuxt.resolver.requireModule(entry) + } catch (error) { + // Show full error + consola.error('ServerMiddleware Error:', error) + + // Placeholder for error + middleware = (req, res, next) => { next(error) } + } + + // Normalize + middleware = this._normalizeMiddleware(middleware) + + // Set entry + middleware.entry = entry + + return middleware + } + + resolveMiddleware (middleware, fallbackRoute = '/') { + // Ensure middleware is normalized + middleware = this._normalizeMiddleware(middleware) + + // Fallback route + if (!middleware.route) { + middleware.route = fallbackRoute + } + + // Resolve final route + middleware.route = ( + (middleware.prefix !== false ? this.options.router.base : '') + + (typeof middleware.route === 'string' ? middleware.route : '') + ).replace(/\/\//g, '/') + + // Strip trailing slash + if (middleware.route.endsWith('/')) { + middleware.route = middleware.route.slice(0, -1) + } + + // Assign _middleware to handle to make accessible from app.stack + middleware.handle._middleware = middleware + + return middleware + } + + useMiddleware (middleware) { + const { route, handle } = this.resolveMiddleware(middleware) + this.app.use(route, handle) + } + + replaceMiddleware (query, middleware) { + let serverStackItem + + if (typeof query === 'string') { + // Search by entry + serverStackItem = this.app.stack.find(({ handle }) => handle._middleware && handle._middleware.entry === query) + } else { + // Search by reference + serverStackItem = this.app.stack.find(({ handle }) => handle === query) + } + + // Stop if item not found + if (!serverStackItem) { + return + } + + // unload middleware + this.unloadMiddleware(serverStackItem) + + // Resolve middleware + const { route, handle } = this.resolveMiddleware(middleware, serverStackItem.route) + + // Update serverStackItem + serverStackItem.handle = handle + + // Error State + serverStackItem.route = route + + // Return updated item + return serverStackItem + } + + unloadMiddleware ({ handle }) { + if (handle._middleware && typeof handle._middleware.unload === 'function') { + handle._middleware.unload() + } + } + + serverMiddlewarePaths () { + return this.app.stack.map(({ handle }) => handle._middleware && handle._middleware.entry).filter(Boolean) + } + + renderRoute () { + return this.renderer.renderRoute.apply(this.renderer, arguments) + } + + loadResources () { + return this.renderer.loadResources.apply(this.renderer, arguments) + } + + renderAndGetWindow (url, opts = {}, { + loadingTimeout = 2000, + loadedCallback = this.globals.loadedCallback, + globals = this.globals + } = {}) { + return renderAndGetWindow(url, opts, { + loadingTimeout, + loadedCallback, + globals + }) + } + + async listen (port, host, socket) { + // Ensure nuxt is ready + await this.nuxt.ready() + + // Create a new listener + const listener = new Listener({ + port: isNaN(parseInt(port)) ? this.options.server.port : port, + host: host || this.options.server.host, + socket: socket || this.options.server.socket, + https: this.options.server.https, + app: this.app, + dev: this.options.dev, + baseURL: this.options.router.base + }) + + // Listen + await listener.listen() + + // Push listener to this.listeners + this.listeners.push(listener) + + await this.nuxt.callHook('listen', listener.server, listener) + + return listener + } + + async close () { + if (this.__closed) { + return + } + this.__closed = true + + await Promise.all(this.listeners.map(l => l.close())) + + this.listeners = [] + + if (typeof this.renderer.close === 'function') { + await this.renderer.close() + } + + this.app.stack.forEach(this.unloadMiddleware) + this.app.removeAllListeners() + this.app = null + + for (const key in this.resources) { + delete this.resources[key] + } + } +} diff --git a/packages/nuxt3/src/utils/cjs.ts b/packages/nuxt3/src/utils/cjs.ts new file mode 100644 index 0000000000..d294ff70a4 --- /dev/null +++ b/packages/nuxt3/src/utils/cjs.ts @@ -0,0 +1,67 @@ +import { join } from 'path' + +export function isExternalDependency (id) { + return /[/\\]node_modules[/\\]/.test(id) +} + +export function clearRequireCache (id) { + if (isExternalDependency(id)) { + return + } + + const entry = getRequireCacheItem(id) + + if (!entry) { + delete require.cache[id] + return + } + + if (entry.parent) { + entry.parent.children = entry.parent.children.filter(e => e.id !== id) + } + + for (const child of entry.children) { + clearRequireCache(child.id) + } + + delete require.cache[id] +} + +export function scanRequireTree (id, files = new Set()) { + if (isExternalDependency(id) || files.has(id)) { + return files + } + + const entry = getRequireCacheItem(id) + + if (!entry) { + files.add(id) + return files + } + + files.add(entry.id) + + for (const child of entry.children) { + scanRequireTree(child.id, files) + } + + return files +} + +export function getRequireCacheItem (id) { + try { + return require.cache[id] + } catch (e) { + } +} + +export function tryRequire (id) { + try { + return require(id) + } catch (e) { + } +} + +export function getPKG (id) { + return tryRequire(join(id, 'package.json')) +} diff --git a/packages/nuxt3/src/utils/constants.ts b/packages/nuxt3/src/utils/constants.ts new file mode 100644 index 0000000000..1af82f818c --- /dev/null +++ b/packages/nuxt3/src/utils/constants.ts @@ -0,0 +1,9 @@ +export const TARGETS = { + server: 'server', + static: 'static' +} + +export const MODES = { + universal: 'universal', + spa: 'spa' +} diff --git a/packages/nuxt3/src/utils/context.ts b/packages/nuxt3/src/utils/context.ts new file mode 100644 index 0000000000..9399809b9f --- /dev/null +++ b/packages/nuxt3/src/utils/context.ts @@ -0,0 +1,21 @@ +import { TARGETS } from './constants' + +export const getContext = function getContext (req, res) { + return { req, res } +} + +export const determineGlobals = function determineGlobals (globalName, globals) { + const _globals = {} + for (const global in globals) { + if (typeof globals[global] === 'function') { + _globals[global] = globals[global](globalName) + } else { + _globals[global] = globals[global] + } + } + return _globals +} + +export const isFullStatic = function (options) { + return !options.dev && !options._legacyGenerate && options.target === TARGETS.static && options.render.ssr +} diff --git a/packages/nuxt3/src/utils/index.ts b/packages/nuxt3/src/utils/index.ts new file mode 100644 index 0000000000..6449631d43 --- /dev/null +++ b/packages/nuxt3/src/utils/index.ts @@ -0,0 +1,11 @@ +export * from './context' +export * from './lang' +export * from './locking' +export * from './resolve' +export * from './route' +export * from './serialize' +export * from './task' +export * from './timer' +export * from './cjs' +export * from './modern' +export * from './constants' diff --git a/packages/nuxt3/src/utils/lang.ts b/packages/nuxt3/src/utils/lang.ts new file mode 100644 index 0000000000..7ecf3fee94 --- /dev/null +++ b/packages/nuxt3/src/utils/lang.ts @@ -0,0 +1,44 @@ +export const encodeHtml = function encodeHtml (str) { + return str.replace(//g, '>') +} + +export const isString = obj => typeof obj === 'string' || obj instanceof String + +export const isNonEmptyString = obj => Boolean(obj && isString(obj)) + +export const isPureObject = obj => !Array.isArray(obj) && typeof obj === 'object' + +export const isUrl = function isUrl (url) { + return ['http', '//'].some(str => url.startsWith(str)) +} + +export const urlJoin = function urlJoin () { + return [].slice + .call(arguments) + .join('/') + .replace(/\/+/g, '/') + .replace(':/', '://') +} + +/** + * Wraps value in array if it is not already an array + * + * @param {any} value + * @return {array} + */ +export const wrapArray = value => Array.isArray(value) ? value : [value] + +const WHITESPACE_REPLACEMENTS = [ + [/[ \t\f\r]+\n/g, '\n'], // strip empty indents + [/{\n{2,}/g, '{\n'], // strip start padding from blocks + [/\n{2,}([ \t\f\r]*})/g, '\n$1'], // strip end padding from blocks + [/\n{3,}/g, '\n\n'], // strip multiple blank lines (1 allowed) + [/\n{2,}$/g, '\n'] // strip blank lines EOF (0 allowed) +] + +export const stripWhitespace = function stripWhitespace (string) { + WHITESPACE_REPLACEMENTS.forEach(([regex, newSubstr]) => { + string = string.replace(regex, newSubstr) + }) + return string +} diff --git a/packages/nuxt3/src/utils/locking.ts b/packages/nuxt3/src/utils/locking.ts new file mode 100644 index 0000000000..9380a53c47 --- /dev/null +++ b/packages/nuxt3/src/utils/locking.ts @@ -0,0 +1,102 @@ +import path from 'path' +import consola from 'consola' +import hash from 'hash-sum' +import fs from 'fs-extra' +import properlock from 'proper-lockfile' +import onExit from 'signal-exit' + +export const lockPaths = new Set() + +export const defaultLockOptions = { + stale: 30000, + onCompromised: err => consola.warn(err) +} + +export function getLockOptions (options) { + return Object.assign({}, defaultLockOptions, options) +} + +export function createLockPath ({ id = 'nuxt', dir, root }) { + const sum = hash(`${root}-${dir}`) + + return path.resolve(root, 'node_modules/.cache/nuxt', `${id}-lock-${sum}`) +} + +export async function getLockPath (config) { + const lockPath = createLockPath(config) + + // the lock is created for the lockPath as ${lockPath}.lock + // so the (temporary) lockPath needs to exist + await fs.ensureDir(lockPath) + + return lockPath +} + +export async function lock ({ id, dir, root, options }) { + const lockPath = await getLockPath({ id, dir, root }) + + try { + const locked = await properlock.check(lockPath) + if (locked) { + consola.fatal(`A lock with id '${id}' already exists on ${dir}`) + } + } catch (e) { + consola.debug(`Check for an existing lock with id '${id}' on ${dir} failed`, e) + } + + let lockWasCompromised = false + let release + + try { + options = getLockOptions(options) + + const onCompromised = options.onCompromised + options.onCompromised = (err) => { + onCompromised(err) + lockWasCompromised = true + } + + release = await properlock.lock(lockPath, options) + } catch (e) {} + + if (!release) { + consola.warn(`Unable to get a lock with id '${id}' on ${dir} (but will continue)`) + return false + } + + if (!lockPaths.size) { + // make sure to always cleanup our temporary lockPaths + onExit(() => { + for (const lockPath of lockPaths) { + fs.removeSync(lockPath) + } + }) + } + + lockPaths.add(lockPath) + + return async function lockRelease () { + try { + await fs.remove(lockPath) + lockPaths.delete(lockPath) + + // release as last so the lockPath is still removed + // when it fails on a compromised lock + await release() + } catch (e) { + if (!lockWasCompromised || !e.message.includes('already released')) { + consola.debug(e) + return + } + + // proper-lockfile doesnt remove lockDir when lock is compromised + // removing it here could cause the 'other' process to throw an error + // as well, but in our case its much more likely the lock was + // compromised due to mtime update timeouts + const lockDir = `${lockPath}.lock` + if (await fs.exists(lockDir)) { + await fs.remove(lockDir) + } + } + } +} diff --git a/packages/nuxt3/src/utils/modern.ts b/packages/nuxt3/src/utils/modern.ts new file mode 100644 index 0000000000..a080a5d7d6 --- /dev/null +++ b/packages/nuxt3/src/utils/modern.ts @@ -0,0 +1,64 @@ +import UAParser from 'ua-parser-js' + +export const ModernBrowsers = { + Edge: '16', + Firefox: '60', + Chrome: '61', + 'Chrome Headless': '61', + Chromium: '61', + Iron: '61', + Safari: '10.1', + Opera: '48', + Yandex: '18', + Vivaldi: '1.14', + 'Mobile Safari': '10.3' +} + +let semver +let __modernBrowsers + +const getModernBrowsers = () => { + if (__modernBrowsers) { + return __modernBrowsers + } + + __modernBrowsers = Object.keys(ModernBrowsers) + .reduce((allBrowsers, browser) => { + allBrowsers[browser] = semver.coerce(ModernBrowsers[browser]) + return allBrowsers + }, {}) + return __modernBrowsers +} + +export const isModernBrowser = (ua) => { + if (!ua) { + return false + } + if (!semver) { + semver = require('semver') + } + const { browser } = UAParser(ua) + const browserVersion = semver.coerce(browser.version) + if (!browserVersion) { + return false + } + const modernBrowsers = getModernBrowsers() + return Boolean(modernBrowsers[browser.name] && semver.gte(browserVersion, modernBrowsers[browser.name])) +} + +export const isModernRequest = (req, modernMode = false) => { + if (modernMode === false) { + return false + } + + const { socket = {}, headers } = req + if (socket._modern === undefined) { + const ua = headers && headers['user-agent'] + socket._modern = isModernBrowser(ua) + } + + return socket._modern +} + +// https://gist.github.com/samthor/64b114e4a4f539915a95b91ffd340acc +export const safariNoModuleFix = '!function(){var e=document,t=e.createElement("script");if(!("noModule"in t)&&"onbeforeload"in t){var n=!1;e.addEventListener("beforeload",function(e){if(e.target===t)n=!0;else if(!e.target.hasAttribute("nomodule")||!n)return;e.preventDefault()},!0),t.type="module",t.src=".",e.head.appendChild(t),t.remove()}}();' diff --git a/packages/nuxt3/src/utils/resolve.ts b/packages/nuxt3/src/utils/resolve.ts new file mode 100644 index 0000000000..4a54c6cfba --- /dev/null +++ b/packages/nuxt3/src/utils/resolve.ts @@ -0,0 +1,109 @@ +import path from 'path' +import consola from 'consola' +import escapeRegExp from 'lodash/escapeRegExp' + +export const startsWithAlias = aliasArray => str => aliasArray.some(c => str.startsWith(c)) + +export const startsWithSrcAlias = startsWithAlias(['@', '~']) + +export const startsWithRootAlias = startsWithAlias(['@@', '~~']) + +export const isWindows = process.platform.startsWith('win') + +export const wp = function wp (p = '') { + if (isWindows) { + return p.replace(/\\/g, '\\\\') + } + return p +} + +// Kept for backward compat (modules may use it from template context) +export const wChunk = function wChunk (p = '') { + return p +} + +const reqSep = /\//g +const sysSep = escapeRegExp(path.sep) +const normalize = string => string.replace(reqSep, sysSep) + +export const r = function r (...args) { + const lastArg = args[args.length - 1] + + if (startsWithSrcAlias(lastArg)) { + return wp(lastArg) + } + + return wp(path.resolve(...args.map(normalize))) +} + +export const relativeTo = function relativeTo (...args) { + const dir = args.shift() + + // Keep webpack inline loader intact + if (args[0].includes('!')) { + const loaders = args.shift().split('!') + + return loaders.concat(relativeTo(dir, loaders.pop(), ...args)).join('!') + } + + // Resolve path + const resolvedPath = r(...args) + + // Check if path is an alias + if (startsWithSrcAlias(resolvedPath)) { + return resolvedPath + } + + // Make correct relative path + let rp = path.relative(dir, resolvedPath) + if (rp[0] !== '.') { + rp = '.' + path.sep + rp + } + + return wp(rp) +} + +export function defineAlias (src, target, prop, opts = {}) { + const { bind = true, warn = false } = opts + + if (Array.isArray(prop)) { + for (const p of prop) { + defineAlias(src, target, p, opts) + } + return + } + + let targetVal = target[prop] + if (bind && typeof targetVal === 'function') { + targetVal = targetVal.bind(target) + } + + let warned = false + + Object.defineProperty(src, prop, { + get: () => { + if (warn && !warned) { + warned = true + consola.warn({ + message: `'${prop}' is deprecated'`, + additional: new Error().stack.split('\n').splice(2).join('\n') + }) + } + return targetVal + } + }) +} + +const isIndex = s => /(.*)\/index\.[^/]+$/.test(s) + +export function isIndexFileAndFolder (pluginFiles) { + // Return early in case the matching file count exceeds 2 (index.js + folder) + if (pluginFiles.length !== 2) { + return false + } + return pluginFiles.some(isIndex) +} + +export const getMainModule = () => { + return require.main || (module && module.main) || module +} diff --git a/packages/nuxt3/src/utils/route.ts b/packages/nuxt3/src/utils/route.ts new file mode 100644 index 0000000000..95dc8f238e --- /dev/null +++ b/packages/nuxt3/src/utils/route.ts @@ -0,0 +1,252 @@ +import path from 'path' +import get from 'lodash/get' +import consola from 'consola' + +import { r } from './resolve' + +export const flatRoutes = function flatRoutes (router, fileName = '', routes = []) { + router.forEach((r) => { + if ([':', '*'].some(c => r.path.includes(c))) { + return + } + if (r.children) { + if (fileName === '' && r.path === '/') { + routes.push('/') + } + + return flatRoutes(r.children, fileName + r.path + '/', routes) + } + fileName = fileName.replace(/\/+/g, '/') + + // if child path is already absolute, do not make any concatenations + if (r.path && r.path.startsWith('/')) { + routes.push(r.path) + } else if (r.path === '' && fileName[fileName.length - 1] === '/') { + routes.push(fileName.slice(0, -1) + r.path) + } else { + routes.push(fileName + r.path) + } + }) + return routes +} + +function cleanChildrenRoutes (routes, isChild = false, routeNameSplitter = '-') { + let start = -1 + const regExpIndex = new RegExp(`${routeNameSplitter}index$`) + const routesIndex = [] + routes.forEach((route) => { + if (regExpIndex.test(route.name) || route.name === 'index') { + // Save indexOf 'index' key in name + const res = route.name.split(routeNameSplitter) + const s = res.indexOf('index') + start = start === -1 || s < start ? s : start + routesIndex.push(res) + } + }) + routes.forEach((route) => { + route.path = isChild ? route.path.replace('/', '') : route.path + if (route.path.includes('?')) { + const names = route.name.split(routeNameSplitter) + const paths = route.path.split('/') + if (!isChild) { + paths.shift() + } // clean first / for parents + routesIndex.forEach((r) => { + const i = r.indexOf('index') - start // children names + if (i < paths.length) { + for (let a = 0; a <= i; a++) { + if (a === i) { + paths[a] = paths[a].replace('?', '') + } + if (a < i && names[a] !== r[a]) { + break + } + } + } + }) + route.path = (isChild ? '' : '/') + paths.join('/') + } + route.name = route.name.replace(regExpIndex, '') + if (route.children) { + if (route.children.find(child => child.path === '')) { + delete route.name + } + route.children = cleanChildrenRoutes(route.children, true, routeNameSplitter) + } + }) + return routes +} + +const DYNAMIC_ROUTE_REGEX = /^\/([:*])/ + +export const sortRoutes = function sortRoutes (routes) { + routes.sort((a, b) => { + if (!a.path.length) { + return -1 + } + if (!b.path.length) { + return 1 + } + // Order: /static, /index, /:dynamic + // Match exact route before index: /login before /index/_slug + if (a.path === '/') { + return DYNAMIC_ROUTE_REGEX.test(b.path) ? -1 : 1 + } + if (b.path === '/') { + return DYNAMIC_ROUTE_REGEX.test(a.path) ? 1 : -1 + } + + let i + let res = 0 + let y = 0 + let z = 0 + const _a = a.path.split('/') + const _b = b.path.split('/') + for (i = 0; i < _a.length; i++) { + if (res !== 0) { + break + } + y = _a[i] === '*' ? 2 : _a[i].includes(':') ? 1 : 0 + z = _b[i] === '*' ? 2 : _b[i].includes(':') ? 1 : 0 + res = y - z + // If a.length >= b.length + if (i === _b.length - 1 && res === 0) { + // unless * found sort by level, then alphabetically + res = _a[i] === '*' ? -1 : ( + _a.length === _b.length ? a.path.localeCompare(b.path) : (_a.length - _b.length) + ) + } + } + + if (res === 0) { + // unless * found sort by level, then alphabetically + res = _a[i - 1] === '*' && _b[i] ? 1 : ( + _a.length === _b.length ? a.path.localeCompare(b.path) : (_a.length - _b.length) + ) + } + return res + }) + + routes.forEach((route) => { + if (route.children) { + sortRoutes(route.children) + } + }) + + return routes +} + +export const createRoutes = function createRoutes ({ + files, + srcDir, + pagesDir = '', + routeNameSplitter = '-', + supportedExtensions = ['vue', 'js'], + trailingSlash +}) { + const routes = [] + files.forEach((file) => { + const keys = file + .replace(new RegExp(`^${pagesDir}`), '') + .replace(new RegExp(`\\.(${supportedExtensions.join('|')})$`), '') + .replace(/\/{2,}/g, '/') + .split('/') + .slice(1) + const route = { name: '', path: '', component: r(srcDir, file) } + let parent = routes + keys.forEach((key, i) => { + // remove underscore only, if its the prefix + const sanitizedKey = key.startsWith('_') ? key.substr(1) : key + + route.name = route.name + ? route.name + routeNameSplitter + sanitizedKey + : sanitizedKey + route.name += key === '_' ? 'all' : '' + route.chunkName = file.replace(new RegExp(`\\.(${supportedExtensions.join('|')})$`), '') + const child = parent.find(parentRoute => parentRoute.name === route.name) + + if (child) { + child.children = child.children || [] + parent = child.children + route.path = '' + } else if (key === 'index' && i + 1 === keys.length) { + route.path += i > 0 ? '' : '/' + } else { + route.path += '/' + getRoutePathExtension(key) + + if (key.startsWith('_') && key.length > 1) { + route.path += '?' + } + } + }) + if (trailingSlash !== undefined) { + route.pathToRegexpOptions = { ...route.pathToRegexpOptions, strict: true } + route.path = route.path.replace(/\/+$/, '') + (trailingSlash ? '/' : '') || '/' + } + + parent.push(route) + }) + + sortRoutes(routes) + return cleanChildrenRoutes(routes, false, routeNameSplitter) +} + +// Guard dir1 from dir2 which can be indiscriminately removed +export const guardDir = function guardDir (options, key1, key2) { + const dir1 = get(options, key1, false) + const dir2 = get(options, key2, false) + + if ( + dir1 && + dir2 && + ( + dir1 === dir2 || + ( + dir1.startsWith(dir2) && + !path.basename(dir1).startsWith(path.basename(dir2)) + ) + ) + ) { + const errorMessage = `options.${key2} cannot be a parent of or same as ${key1}` + consola.fatal(errorMessage) + throw new Error(errorMessage) + } +} + +const getRoutePathExtension = (key) => { + if (key === '_') { + return '*' + } + + if (key.startsWith('_')) { + return `:${key.substr(1)}` + } + + return key +} + +export const promisifyRoute = function promisifyRoute (fn, ...args) { + // If routes is an array + if (Array.isArray(fn)) { + return Promise.resolve(fn) + } + // If routes is a function expecting a callback + if (fn.length === arguments.length) { + return new Promise((resolve, reject) => { + fn((err, routeParams) => { + if (err) { + reject(err) + } + resolve(routeParams) + }, ...args) + }) + } + let promise = fn(...args) + if ( + !promise || + (!(promise instanceof Promise) && typeof promise.then !== 'function') + ) { + promise = Promise.resolve(promise) + } + return promise +} diff --git a/packages/nuxt3/src/utils/serialize.ts b/packages/nuxt3/src/utils/serialize.ts new file mode 100644 index 0000000000..f1b2ebdf09 --- /dev/null +++ b/packages/nuxt3/src/utils/serialize.ts @@ -0,0 +1,52 @@ +import serialize from 'serialize-javascript' + +export function normalizeFunctions (obj) { + if (typeof obj !== 'object' || Array.isArray(obj) || obj === null) { + return obj + } + for (const key in obj) { + if (key === '__proto__' || key === 'constructor') { + continue + } + const val = obj[key] + if (val !== null && typeof val === 'object' && !Array.isArray(obj)) { + obj[key] = normalizeFunctions(val) + } + if (typeof obj[key] === 'function') { + const asString = obj[key].toString() + const match = asString.match(/^([^{(]+)=>\s*([\0-\uFFFF]*)/) + if (match) { + const fullFunctionBody = match[2].match(/^{?(\s*return\s+)?([\0-\uFFFF]*?)}?$/) + let functionBody = fullFunctionBody[2].trim() + if (fullFunctionBody[1] || !match[2].trim().match(/^\s*{/)) { + functionBody = `return ${functionBody}` + } + // eslint-disable-next-line no-new-func + obj[key] = new Function(...match[1].split(',').map(arg => arg.trim()), functionBody) + } + } + } + return obj +} + +export function serializeFunction (func) { + let open = false + func = normalizeFunctions(func) + return serialize(func) + .replace(serializeFunction.assignmentRE, (_, spaces) => { + return `${spaces}: function (` + }) + .replace(serializeFunction.internalFunctionRE, (_, spaces, name, args) => { + if (open) { + return `${spaces}${name}: function (${args}) {` + } else { + open = true + return _ + } + }) + .replace(`${func.name || 'function'}(`, 'function (') + .replace('function function', 'function') +} + +serializeFunction.internalFunctionRE = /^(\s*)(?!(?:if)|(?:for)|(?:while)|(?:switch)|(?:catch))(\w+)\s*\((.*?)\)\s*\{/gm +serializeFunction.assignmentRE = /^(\s*):(\w+)\(/gm diff --git a/packages/nuxt3/src/utils/task.ts b/packages/nuxt3/src/utils/task.ts new file mode 100644 index 0000000000..a6a82995c8 --- /dev/null +++ b/packages/nuxt3/src/utils/task.ts @@ -0,0 +1,36 @@ +export const sequence = function sequence (tasks, fn) { + return tasks.reduce( + (promise, task) => promise.then(() => fn(task)), + Promise.resolve() + ) +} + +export const parallel = function parallel (tasks, fn) { + return Promise.all(tasks.map(fn)) +} + +export const chainFn = function chainFn (base, fn) { + if (typeof fn !== 'function') { + return base + } + return function (...args) { + if (typeof base !== 'function') { + return fn.apply(this, args) + } + let baseResult = base.apply(this, args) + // Allow function to mutate the first argument instead of returning the result + if (baseResult === undefined) { + [baseResult] = args + } + const fnResult = fn.call( + this, + baseResult, + ...Array.prototype.slice.call(args, 1) + ) + // Return mutated argument if no result was returned + if (fnResult === undefined) { + return baseResult + } + return fnResult + } +} diff --git a/packages/nuxt3/src/utils/timer.ts b/packages/nuxt3/src/utils/timer.ts new file mode 100644 index 0000000000..2add0401af --- /dev/null +++ b/packages/nuxt3/src/utils/timer.ts @@ -0,0 +1,65 @@ +async function promiseFinally (fn, finalFn) { + let result + try { + if (typeof fn === 'function') { + result = await fn() + } else { + result = await fn + } + } finally { + finalFn() + } + return result +} + +export const timeout = function timeout (fn, ms, msg) { + let timerId + const warpPromise = promiseFinally(fn, () => clearTimeout(timerId)) + const timerPromise = new Promise((resolve, reject) => { + timerId = setTimeout(() => reject(new Error(msg)), ms) + }) + return Promise.race([warpPromise, timerPromise]) +} + +export const waitFor = function waitFor (ms) { + return new Promise(resolve => setTimeout(resolve, ms || 0)) +} +export class Timer { + constructor () { + this._times = new Map() + } + + start (name, description) { + const time = { + name, + description, + start: this.hrtime() + } + this._times.set(name, time) + return time + } + + end (name) { + if (this._times.has(name)) { + const time = this._times.get(name) + time.duration = this.hrtime(time.start) + this._times.delete(name) + return time + } + } + + hrtime (start) { + const useBigInt = typeof process.hrtime.bigint === 'function' + if (start) { + const end = useBigInt ? process.hrtime.bigint() : process.hrtime(start) + return useBigInt + ? (end - start) / BigInt(1000000) + : (end[0] * 1e3) + (end[1] * 1e-6) + } + return useBigInt ? process.hrtime.bigint() : process.hrtime() + } + + clear () { + this._times.clear() + } +} diff --git a/packages/nuxt3/src/vue-app/app.pages.vue b/packages/nuxt3/src/vue-app/app.pages.vue new file mode 100644 index 0000000000..421ece8591 --- /dev/null +++ b/packages/nuxt3/src/vue-app/app.pages.vue @@ -0,0 +1,46 @@ + + + + + diff --git a/packages/nuxt3/src/vue-app/app.tutorial.vue b/packages/nuxt3/src/vue-app/app.tutorial.vue new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/nuxt3/src/vue-app/components/index.js b/packages/nuxt3/src/vue-app/components/index.js new file mode 100644 index 0000000000..cd0183132b --- /dev/null +++ b/packages/nuxt3/src/vue-app/components/index.js @@ -0,0 +1 @@ +// nothing here diff --git a/packages/nuxt3/src/vue-app/index.js b/packages/nuxt3/src/vue-app/index.js new file mode 100644 index 0000000000..8c573c09fc --- /dev/null +++ b/packages/nuxt3/src/vue-app/index.js @@ -0,0 +1 @@ +export { init } from './nuxt' diff --git a/packages/nuxt3/src/vue-app/nuxt.js b/packages/nuxt3/src/vue-app/nuxt.js new file mode 100644 index 0000000000..6acf9417f6 --- /dev/null +++ b/packages/nuxt3/src/vue-app/nuxt.js @@ -0,0 +1,28 @@ +import Hookable from 'hookable' +import { defineGetter } from './utils' + +class Nuxt extends Hookable { + constructor ({ app, ssrContext, globalName }) { + super() + this.app = app + this.ssrContext = ssrContext + this.globalName = globalName + } + + provide (name, value) { + const $name = '$' + name + defineGetter(this.app, $name, value) + defineGetter(this.app.config.globalProperties, $name, value) + } +} + +export async function init ({ app, plugins, ssrContext, globalName = 'nuxt' }) { + const nuxt = new Nuxt({ app, ssrContext, globalName }) + nuxt.provide('nuxt', nuxt) + + const inject = nuxt.provide.bind(nuxt) + + for (const plugin of plugins) { + await plugin(nuxt, inject) + } +} diff --git a/packages/nuxt3/src/vue-app/nuxt/entry.client.js b/packages/nuxt3/src/vue-app/nuxt/entry.client.js new file mode 100644 index 0000000000..0c79142316 --- /dev/null +++ b/packages/nuxt3/src/vue-app/nuxt/entry.client.js @@ -0,0 +1,25 @@ +import { createSSRApp } from 'vue' +import plugins from './plugins.client' +import { init } from 'nuxt-app' +import App from '<%= appPath %>' + +async function initApp () { + const app = createSSRApp(App) + + await init({ + app, + plugins + }) + + await app.$nuxt.callHook('client:create') + + app.mount('#__nuxt') + + await app.$nuxt.callHook('client:mounted') + + console.log('App ready:', app) // eslint-disable-line no-console +} + +initApp().catch((error) => { + console.error('Error while mounting app:', error) // eslint-disable-line no-console +}) diff --git a/packages/nuxt3/src/vue-app/nuxt/entry.server.js b/packages/nuxt3/src/vue-app/nuxt/entry.server.js new file mode 100644 index 0000000000..c8658d0e73 --- /dev/null +++ b/packages/nuxt3/src/vue-app/nuxt/entry.server.js @@ -0,0 +1,19 @@ +import { createApp } from 'vue' + +import { init } from 'nuxt-app' +import plugins from 'nuxt-build/plugins.server' +import App from '<%= appPath %>' + +export default async function createNuxtAppServer (ssrContext = {}) { + const app = createApp(App) + + await init({ + app, + plugins, + ssrContext + }) + + await app.$nuxt.callHook('server:create') + + return app +} diff --git a/packages/nuxt3/src/vue-app/nuxt/layouts/default.vue b/packages/nuxt3/src/vue-app/nuxt/layouts/default.vue new file mode 100644 index 0000000000..5d3ae9c407 --- /dev/null +++ b/packages/nuxt3/src/vue-app/nuxt/layouts/default.vue @@ -0,0 +1,3 @@ + diff --git a/packages/nuxt3/src/vue-app/nuxt/plugins.client.js b/packages/nuxt3/src/vue-app/nuxt/plugins.client.js new file mode 100644 index 0000000000..42478b7396 --- /dev/null +++ b/packages/nuxt3/src/vue-app/nuxt/plugins.client.js @@ -0,0 +1,5 @@ +import sharedPlugins from './plugins' + +export default [ + ...sharedPlugins +] diff --git a/packages/nuxt3/src/vue-app/nuxt/plugins.js b/packages/nuxt3/src/vue-app/nuxt/plugins.js new file mode 100644 index 0000000000..fde82e51b7 --- /dev/null +++ b/packages/nuxt3/src/vue-app/nuxt/plugins.js @@ -0,0 +1,11 @@ +// import router from 'nuxt-app/plugins/router' +import state from 'nuxt-app/plugins/state' +import components from 'nuxt-app/plugins/components' +import legacy from 'nuxt-app/plugins/legacy' + +export default [ + // router, + state, + components, + legacy +] diff --git a/packages/nuxt3/src/vue-app/nuxt/plugins.server.js b/packages/nuxt3/src/vue-app/nuxt/plugins.server.js new file mode 100644 index 0000000000..b97c807020 --- /dev/null +++ b/packages/nuxt3/src/vue-app/nuxt/plugins.server.js @@ -0,0 +1,7 @@ +import sharedPlugins from './plugins' +import preload from 'nuxt-app/plugins/preload' + +export default [ + ...sharedPlugins, + preload +] diff --git a/packages/nuxt3/src/vue-app/nuxt/routes.js b/packages/nuxt3/src/vue-app/nuxt/routes.js new file mode 100644 index 0000000000..87adf0b0f5 --- /dev/null +++ b/packages/nuxt3/src/vue-app/nuxt/routes.js @@ -0,0 +1,19 @@ +const Index = () => import('~/pages' /* webpackChunkName: "Home" */) +const About = () => import('~/pages/about' /* webpackChunkName: "About" */) +const Custom = () => import('~/pages/custom' /* webpackChunkName: "Custom" */) + +export default [ + { + path: '', + __file: '@/pages/index.vue', + component: Index + }, + { + path: '/about', + component: About + }, + { + path: '/custom', + component: Custom + } +] diff --git a/packages/nuxt3/src/vue-app/nuxt/views/app.template.html b/packages/nuxt3/src/vue-app/nuxt/views/app.template.html new file mode 100644 index 0000000000..6fc4ebb42c --- /dev/null +++ b/packages/nuxt3/src/vue-app/nuxt/views/app.template.html @@ -0,0 +1,9 @@ + + + + {{ HEAD }} + + +
{{ APP }}
+ + diff --git a/packages/nuxt3/src/vue-app/nuxt/views/error.html b/packages/nuxt3/src/vue-app/nuxt/views/error.html new file mode 100644 index 0000000000..c7354d5518 --- /dev/null +++ b/packages/nuxt3/src/vue-app/nuxt/views/error.html @@ -0,0 +1,23 @@ + + + +Server error + + + + + +
+
+ +
Server error
+
{{ message }}
+
+ +
+ + diff --git a/packages/nuxt3/src/vue-app/plugins/components.js b/packages/nuxt3/src/vue-app/plugins/components.js new file mode 100644 index 0000000000..41d6a8fea3 --- /dev/null +++ b/packages/nuxt3/src/vue-app/plugins/components.js @@ -0,0 +1,11 @@ +// import { h, defineComponent } from 'vue' +import { Link } from 'vue-router' + +// const NuxtLink = defineComponent({ +// extends: Link +// }) + +export default function components ({ app }) { + app.component('NuxtLink', Link) + app.component('NLink', Link) // TODO: deprecate +} diff --git a/packages/nuxt3/src/vue-app/plugins/legacy.js b/packages/nuxt3/src/vue-app/plugins/legacy.js new file mode 100644 index 0000000000..5700cb818d --- /dev/null +++ b/packages/nuxt3/src/vue-app/plugins/legacy.js @@ -0,0 +1,15 @@ +export default function legacy ({ app }) { + app.$nuxt.context = {} + + if (process.client) { + const legacyApp = { ...app } + legacyApp.$root = legacyApp + window[app.$nuxt.globalName] = legacyApp + } + + if (process.server) { + const { ssrContext } = app.$nuxt + app.$nuxt.context.req = ssrContext.req + app.$nuxt.context.res = ssrContext.res + } +} diff --git a/packages/nuxt3/src/vue-app/plugins/preload.js b/packages/nuxt3/src/vue-app/plugins/preload.js new file mode 100644 index 0000000000..4c3b658107 --- /dev/null +++ b/packages/nuxt3/src/vue-app/plugins/preload.js @@ -0,0 +1,9 @@ +export default function preload ({ app }) { + app.mixin({ + beforeCreate () { + const { _registeredComponents } = this.$nuxt.ssrContext + const { __moduleIdentifier } = this.$options + _registeredComponents.push(__moduleIdentifier) + } + }) +} diff --git a/packages/nuxt3/src/vue-app/plugins/router.js b/packages/nuxt3/src/vue-app/plugins/router.js new file mode 100644 index 0000000000..0aaeb769d9 --- /dev/null +++ b/packages/nuxt3/src/vue-app/plugins/router.js @@ -0,0 +1,37 @@ +import { ref } from 'vue' +import { createRouter, createWebHistory, createMemoryHistory } from 'vue-router' + +import routes from 'nuxt-build/routes' + +export default function router ({ app }) { + const routerHistory = process.client + ? createWebHistory() + : createMemoryHistory() + + const router = createRouter({ + history: routerHistory, + routes + }) + app.use(router) + + const previousRoute = ref() + router.afterEach((to, from) => { + previousRoute.value = from + }) + + Object.defineProperty(app.config.globalProperties, 'previousRoute', { + get: () => previousRoute.value + }) + + if (process.server) { + app.$nuxt.hook('server:create', async () => { + router.push(app.$nuxt.ssrContext.url) + await router.isReady() + }) + } else { + app.$nuxt.hook('client:create', async () => { + router.push(router.history.location.fullPath) + await router.isReady() + }) + } +} diff --git a/packages/nuxt3/src/vue-app/plugins/state.js b/packages/nuxt3/src/vue-app/plugins/state.js new file mode 100644 index 0000000000..be0a83f561 --- /dev/null +++ b/packages/nuxt3/src/vue-app/plugins/state.js @@ -0,0 +1,13 @@ +export default function state ({ app }) { + if (process.server) { + app.$nuxt.state = { + serverRendered: true + // data, fetch, vuex, etc. + } + app.$nuxt.ssrContext.nuxt = app.$nuxt.state + } + + if (process.client) { + app.$nuxt.state = window.__NUXT__ || {} + } +} diff --git a/packages/nuxt3/src/vue-app/template.ts b/packages/nuxt3/src/vue-app/template.ts new file mode 100644 index 0000000000..ac901a7183 --- /dev/null +++ b/packages/nuxt3/src/vue-app/template.ts @@ -0,0 +1,12 @@ +import path from 'path' +import globby from 'globby' + +const dir = path.join(__dirname, 'nuxt') +const files = globby.sync(path.join(dir, '/**')) + .map(f => f.replace(dir + path.sep, '')) // TODO: workaround + +export default { + dependencies: {}, + dir, + files +} diff --git a/packages/nuxt3/src/vue-app/utils.js b/packages/nuxt3/src/vue-app/utils.js new file mode 100644 index 0000000000..8286417024 --- /dev/null +++ b/packages/nuxt3/src/vue-app/utils.js @@ -0,0 +1,3 @@ +export function defineGetter (obj, key, val) { + Object.defineProperty(obj, key, { get: () => val }) +} diff --git a/packages/nuxt3/src/vue-app/vetur/nuxt-attributes.json b/packages/nuxt3/src/vue-app/vetur/nuxt-attributes.json new file mode 100644 index 0000000000..d6bbfa21fb --- /dev/null +++ b/packages/nuxt3/src/vue-app/vetur/nuxt-attributes.json @@ -0,0 +1,38 @@ +{ + "nuxtChildKey": { + "description": "This prop will be set to , useful to make transitions inside a dynamic page and different route. Default: `$route.fullPath`" + }, + "to": { + "description": "Denotes the target route of the link. When clicked, the value of the to prop will be passed to router.push() internally, so the value can be either a string or a location descriptor object." + }, + "prefetch": { + "type": "boolean", + "description": "Prefetch route target (overrides router.prefetchLinks value in nuxt.config.js)." + }, + "no-prefetch": { + "description": "Avoid prefetching route target." + }, + "replace": { + "type": "boolean", + "description": "Setting replace prop will call router.replace() instead of router.push() when clicked, so the navigation will not leave a history record." + }, + "append": { + "type": "boolean", + "description": "Setting append prop always appends the relative path to the current path. For example, assuming we are navigating from /a to a relative link b, without append we will end up at /b, but with append we will end up at /a/b." + }, + "tag": { + "description": "Specify which tag to render to, and it will still listen to click events for navigation." + }, + "active-class": { + "description": "Configure the active CSS class applied when the link is active." + }, + "exact": { + "description": "The default active class matching behavior is inclusive match. For example, will get this class applied as long as the current path starts with /a/ or is /a.\nOne consequence of this is that will be active for every route! To force the link into \"exact match mode\", use the exact prop: " + }, + "event": { + "description": "Specify the event(s) that can trigger the link navigation." + }, + "exact-active-class": { + "description": "Configure the active CSS class applied when the link is active with exact match. Note the default value can also be configured globally via the linkExactActiveClass router constructor option." + } +} diff --git a/packages/nuxt3/src/vue-app/vetur/nuxt-tags.json b/packages/nuxt3/src/vue-app/vetur/nuxt-tags.json new file mode 100644 index 0000000000..3fc2c28194 --- /dev/null +++ b/packages/nuxt3/src/vue-app/vetur/nuxt-tags.json @@ -0,0 +1,47 @@ +{ + "nuxt": { + "attributes": [ + "nuxtChildKey" + ], + "description": "Component to render the current nuxt page." + }, + "n-child": { + "description": "Component for displaying the children components in a nested route." + }, + "nuxt-child": { + "description": "Component for displaying the children components in a nested route." + }, + "n-link": { + "attributes": [ + "to", + "replace", + "append", + "tag", + "active-class", + "exact", + "event", + "exact-active-class", + "prefetch", + "no-prefetch" + ], + "description": "Component for navigating between Nuxt pages." + }, + "nuxt-link": { + "attributes": [ + "to", + "replace", + "append", + "tag", + "active-class", + "exact", + "event", + "exact-active-class", + "prefetch", + "no-prefetch" + ], + "description": "Component for navigating between Nuxt pages." + }, + "no-ssr": { + "description": "Component for excluding a part of your app from server-side rendering." + } +} diff --git a/packages/nuxt3/src/vue-renderer/index.ts b/packages/nuxt3/src/vue-renderer/index.ts new file mode 100644 index 0000000000..0567e6025f --- /dev/null +++ b/packages/nuxt3/src/vue-renderer/index.ts @@ -0,0 +1 @@ +export { default as VueRenderer } from './renderer' diff --git a/packages/nuxt3/src/vue-renderer/renderer.ts b/packages/nuxt3/src/vue-renderer/renderer.ts new file mode 100644 index 0000000000..5ffee5efe2 --- /dev/null +++ b/packages/nuxt3/src/vue-renderer/renderer.ts @@ -0,0 +1,386 @@ +import path from 'path' +import fs from 'fs-extra' +import consola from 'consola' +import template from 'lodash/template' +import { TARGETS, isModernRequest, waitFor } from 'src/utils' + +import SPARenderer from './renderers/spa' +import SSRRenderer from './renderers/ssr' +import ModernRenderer from './renderers/modern' + +export default class VueRenderer { + constructor (context) { + this.serverContext = context + this.options = this.serverContext.options + + // Will be set by createRenderer + this.renderer = { + ssr: undefined, + modern: undefined, + spa: undefined + } + + // Renderer runtime resources + Object.assign(this.serverContext.resources, { + clientManifest: undefined, + modernManifest: undefined, + serverManifest: undefined, + ssrTemplate: undefined, + spaTemplate: undefined, + errorTemplate: this.parseTemplate('Nuxt.js Internal Server Error') + }) + + // Default status + this._state = 'created' + this._error = null + } + + ready () { + if (!this._readyPromise) { + this._state = 'loading' + this._readyPromise = this._ready() + .then(() => { + this._state = 'ready' + return this + }) + .catch((error) => { + this._state = 'error' + this._error = error + throw error + }) + } + + return this._readyPromise + } + + async _ready () { + // Resolve dist path + this.distPath = path.resolve(this.options.buildDir, 'dist', 'server') + + // -- Development mode -- + if (this.options.dev) { + this.serverContext.nuxt.hook('build:resources', mfs => this.loadResources(mfs)) + return + } + + // -- Production mode -- + + // Try once to load SSR resources from fs + await this.loadResources(fs) + + // Without using `nuxt start` (programmatic, tests and generate) + if (!this.options._start) { + this.serverContext.nuxt.hook('build:resources', () => this.loadResources(fs)) + return + } + + // Verify resources + if (this.options.modern && !this.isModernReady) { + throw new Error( + `No modern build files found in ${this.distPath}.\nUse either \`nuxt build --modern\` or \`modern\` option to build modern files.` + ) + } else if (!this.isReady) { + throw new Error( + `No build files found in ${this.distPath}.\nUse either \`nuxt build\` or \`builder.build()\` or start nuxt in development mode.` + ) + } + } + + async loadResources (_fs) { + const updated = [] + + const readResource = async (fileName, encoding) => { + try { + const fullPath = path.resolve(this.distPath, fileName) + + if (!await _fs.exists(fullPath)) { + return + } + const contents = await _fs.readFile(fullPath, encoding) + + return contents + } catch (err) { + consola.error('Unable to load resource:', fileName, err) + } + } + + for (const resourceName in this.resourceMap) { + const { fileName, transform, encoding } = this.resourceMap[resourceName] + + // Load resource + let resource = await readResource(fileName, encoding) + + // Skip unavailable resources + if (!resource) { + continue + } + + // Apply transforms + if (typeof transform === 'function') { + resource = await transform(resource, { readResource }) + } + + // Update resource + this.serverContext.resources[resourceName] = resource + updated.push(resourceName) + } + + // Load templates + await this.loadTemplates() + + await this.serverContext.nuxt.callHook('render:resourcesLoaded', this.serverContext.resources) + + // Detect if any resource updated + if (updated.length > 0) { + // Create new renderer + this.createRenderer() + } + } + + async loadTemplates () { + // Reload error template + const errorTemplatePath = path.resolve(this.options.buildDir, 'views/error.html') + + if (await fs.exists(errorTemplatePath)) { + const errorTemplate = await fs.readFile(errorTemplatePath, 'utf8') + this.serverContext.resources.errorTemplate = this.parseTemplate(errorTemplate) + } + + // Reload loading template + const loadingHTMLPath = path.resolve(this.options.buildDir, 'loading.html') + + if (await fs.exists(loadingHTMLPath)) { + this.serverContext.resources.loadingHTML = await fs.readFile(loadingHTMLPath, 'utf8') + this.serverContext.resources.loadingHTML = this.serverContext.resources.loadingHTML.replace(/\r|\n|[\t\s]{3,}/g, '') + } else { + this.serverContext.resources.loadingHTML = '' + } + } + + // TODO: Remove in Nuxt 3 + get noSSR () { /* Backward compatibility */ + return this.options.render.ssr === false + } + + get SSR () { + return this.options.render.ssr === true + } + + get isReady () { + // SPA + if (!this.serverContext.resources.spaTemplate || !this.renderer.spa) { + return false + } + + // SSR + if (this.SSR && (!this.serverContext.resources.ssrTemplate || !this.renderer.ssr)) { + return false + } + + return true + } + + get isModernReady () { + return this.isReady && this.serverContext.resources.modernManifest + } + + // TODO: Remove in Nuxt 3 + get isResourcesAvailable () { /* Backward compatibility */ + return this.isReady + } + + detectModernBuild () { + const { options, resources } = this.serverContext + if ([false, 'client', 'server'].includes(options.modern)) { + return + } + + const isExplicitStaticModern = options.target === TARGETS.static && options.modern + if (!resources.modernManifest && !isExplicitStaticModern) { + options.modern = false + return + } + + options.modern = options.render.ssr ? 'server' : 'client' + consola.info(`Modern bundles are detected. Modern mode (\`${options.modern}\`) is enabled now.`) + } + + createRenderer () { + // Resource clientManifest is always required + if (!this.serverContext.resources.clientManifest) { + return + } + + this.detectModernBuild() + + // Create SPA renderer + if (this.serverContext.resources.spaTemplate) { + this.renderer.spa = new SPARenderer(this.serverContext) + } + + // Skip the rest if SSR resources are not available + if (this.serverContext.resources.ssrTemplate && this.serverContext.resources.serverManifest) { + // Create bundle renderer for SSR + this.renderer.ssr = new SSRRenderer(this.serverContext) + + if (this.options.modern !== false) { + this.renderer.modern = new ModernRenderer(this.serverContext) + } + } + } + + renderSPA (renderContext) { + return this.renderer.spa.render(renderContext) + } + + renderSSR (renderContext) { + // Call renderToString from the bundleRenderer and generate the HTML (will update the renderContext as well) + const renderer = renderContext.modern ? this.renderer.modern : this.renderer.ssr + return renderer.render(renderContext) + } + + async renderRoute (url, renderContext = {}, _retried = 0) { + /* istanbul ignore if */ + if (!this.isReady) { + // Fall-back to loading-screen if enabled + if (this.options.build.loadingScreen) { + // Tell nuxt middleware to use `server:nuxt:renderLoading hook + return false + } + + // Retry + const retryLimit = this.options.dev ? 60 : 3 + if (_retried < retryLimit && this._state !== 'error') { + await this.ready().then(() => waitFor(1000)) + return this.renderRoute(url, renderContext, _retried + 1) + } + + // Throw Error + switch (this._state) { + case 'created': + throw new Error('Renderer ready() is not called! Please ensure `nuxt.ready()` is called and awaited.') + case 'loading': + throw new Error('Renderer is loading.') + case 'error': + throw this._error + case 'ready': + throw new Error(`Renderer resources are not loaded! Please check possible console errors and ensure dist (${this.distPath}) exists.`) + default: + throw new Error('Renderer is in unknown state!') + } + } + + // Log rendered url + consola.debug(`Rendering url ${url}`) + + // Add url to the renderContext + renderContext.url = url + // Add target to the renderContext + renderContext.target = this.serverContext.nuxt.options.target + + const { req = {}, res = {} } = renderContext + + // renderContext.spa + if (renderContext.spa === undefined) { + // TODO: Remove reading from renderContext.res in Nuxt3 + renderContext.spa = !this.SSR || req.spa || res.spa + } + + // renderContext.modern + if (renderContext.modern === undefined) { + const modernMode = this.options.modern + renderContext.modern = modernMode === 'client' || isModernRequest(req, modernMode) + } + + // Set runtime config on renderContext + renderContext.runtimeConfig = { + private: renderContext.spa ? {} : { ...this.options.privateRuntimeConfig }, + public: { ...this.options.publicRuntimeConfig } + } + + // Call renderContext hook + await this.serverContext.nuxt.callHook('vue-renderer:context', renderContext) + + // Render SPA or SSR + return renderContext.spa + ? this.renderSPA(renderContext) + : this.renderSSR(renderContext) + } + + get resourceMap () { + return { + clientManifest: { + fileName: 'client.manifest.json', + transform: src => JSON.parse(src) + }, + modernManifest: { + fileName: 'modern.manifest.json', + transform: src => JSON.parse(src) + }, + serverManifest: { + fileName: 'server.manifest.json', + // BundleRenderer needs resolved contents + transform: async (src, { readResource }) => { + const serverManifest = JSON.parse(src) + + const readResources = async (obj) => { + const _obj = {} + await Promise.all(Object.keys(obj).map(async (key) => { + _obj[key] = await readResource(obj[key]) + })) + return _obj + } + + const [files, maps] = await Promise.all([ + readResources(serverManifest.files), + readResources(serverManifest.maps) + ]) + + // Try to parse sourcemaps + for (const map in maps) { + if (maps[map] && maps[map].version) { + continue + } + try { + maps[map] = JSON.parse(maps[map]) + } catch (e) { + maps[map] = { version: 3, sources: [], mappings: '' } + } + } + + return { + ...serverManifest, + files, + maps + } + } + }, + ssrTemplate: { + fileName: 'index.ssr.html', + transform: src => this.parseTemplate(src) + }, + spaTemplate: { + fileName: 'index.spa.html', + transform: src => this.parseTemplate(src) + } + } + } + + parseTemplate (templateStr) { + return template(templateStr, { + interpolate: /{{([\s\S]+?)}}/g, + evaluate: /{%([\s\S]+?)%}/g + }) + } + + close () { + if (this.__closed) { + return + } + this.__closed = true + + for (const key in this.renderer) { + delete this.renderer[key] + } + } +} diff --git a/packages/nuxt3/src/vue-renderer/renderers/base.ts b/packages/nuxt3/src/vue-renderer/renderers/base.ts new file mode 100644 index 0000000000..ad1d3e3def --- /dev/null +++ b/packages/nuxt3/src/vue-renderer/renderers/base.ts @@ -0,0 +1,19 @@ +export default class BaseRenderer { + constructor (serverContext) { + this.serverContext = serverContext + this.options = serverContext.options + } + + renderTemplate (templateFn, opts) { + // Fix problem with HTMLPlugin's minify option (#3392) + opts.html_attrs = opts.HTML_ATTRS + opts.head_attrs = opts.HEAD_ATTRS + opts.body_attrs = opts.BODY_ATTRS + + return templateFn(opts) + } + + render () { + throw new Error('`render()` needs to be implemented') + } +} diff --git a/packages/nuxt3/src/vue-renderer/renderers/modern.ts b/packages/nuxt3/src/vue-renderer/renderers/modern.ts new file mode 100644 index 0000000000..0b0e4b64da --- /dev/null +++ b/packages/nuxt3/src/vue-renderer/renderers/modern.ts @@ -0,0 +1,123 @@ +import { isUrl, urlJoin, safariNoModuleFix } from 'src/utils' +import SSRRenderer from './ssr' + +export default class ModernRenderer extends SSRRenderer { + constructor (serverContext) { + super(serverContext) + + const { build: { publicPath }, router: { base } } = this.options + this.publicPath = isUrl(publicPath) ? publicPath : urlJoin(base, publicPath) + } + + get assetsMapping () { + if (this._assetsMapping) { + return this._assetsMapping + } + + const { clientManifest, modernManifest } = this.serverContext.resources + const legacyAssets = clientManifest.assetsMapping + const modernAssets = modernManifest.assetsMapping + const mapping = {} + + Object.keys(legacyAssets).forEach((componentHash) => { + const modernComponentAssets = modernAssets[componentHash] || [] + legacyAssets[componentHash].forEach((legacyAssetName, index) => { + mapping[legacyAssetName] = modernComponentAssets[index] + }) + }) + delete clientManifest.assetsMapping + delete modernManifest.assetsMapping + this._assetsMapping = mapping + + return mapping + } + + get isServerMode () { + return this.options.modern === 'server' + } + + get rendererOptions () { + const rendererOptions = super.rendererOptions + if (this.isServerMode) { + rendererOptions.clientManifest = this.serverContext.resources.modernManifest + } + return rendererOptions + } + + renderScripts (renderContext) { + const scripts = super.renderScripts(renderContext) + + if (this.isServerMode) { + return scripts + } + + const scriptPattern = /]*?src="([^"]*?)" defer><\/script>/g + + const modernScripts = scripts.replace(scriptPattern, (scriptTag, jsFile) => { + const legacyJsFile = jsFile.replace(this.publicPath, '') + const modernJsFile = this.assetsMapping[legacyJsFile] + if (!modernJsFile) { + return scriptTag + } + const moduleTag = scriptTag + .replace('${safariNoModuleFix}` + + return safariNoModuleFixScript + modernScripts + } + + getModernFiles (legacyFiles = []) { + const modernFiles = [] + + for (const legacyJsFile of legacyFiles) { + const modernFile = { ...legacyJsFile, modern: true } + if (modernFile.asType === 'script') { + const file = this.assetsMapping[legacyJsFile.file] + modernFile.file = file + modernFile.fileWithoutQuery = file.replace(/\?.*/, '') + } + modernFiles.push(modernFile) + } + + return modernFiles + } + + getPreloadFiles (renderContext) { + const preloadFiles = super.getPreloadFiles(renderContext) + // In eligible server modern mode, preloadFiles are modern bundles from modern renderer + return this.isServerMode ? preloadFiles : this.getModernFiles(preloadFiles) + } + + renderResourceHints (renderContext) { + const resourceHints = super.renderResourceHints(renderContext) + if (this.isServerMode) { + return resourceHints + } + + const linkPattern = /]*?href="([^"]*?)"[^>]*?as="script"[^>]*?>/g + + return resourceHints.replace(linkPattern, (linkTag, jsFile) => { + const legacyJsFile = jsFile.replace(this.publicPath, '') + const modernJsFile = this.assetsMapping[legacyJsFile] + if (!modernJsFile) { + return '' + } + return linkTag + .replace('rel="preload"', 'rel="modulepreload"') + .replace(legacyJsFile, modernJsFile) + }) + } + + render (renderContext) { + if (this.isServerMode) { + renderContext.res.setHeader('Vary', 'User-Agent') + } + return super.render(renderContext) + } +} diff --git a/packages/nuxt3/src/vue-renderer/renderers/spa.ts b/packages/nuxt3/src/vue-renderer/renderers/spa.ts new file mode 100644 index 0000000000..78943a43ba --- /dev/null +++ b/packages/nuxt3/src/vue-renderer/renderers/spa.ts @@ -0,0 +1,204 @@ +import { extname } from 'path' +import cloneDeep from 'lodash/cloneDeep' +import VueMeta from 'vue-meta' +import LRU from 'lru-cache' +import devalue from '@nuxt/devalue' +import { TARGETS, isModernRequest } from 'src/utils' +import BaseRenderer from './base' + +export default class SPARenderer extends BaseRenderer { + constructor (serverContext) { + super(serverContext) + + this.cache = new LRU() + + this.vueMetaConfig = { + ssrAppId: '1', + ...this.options.vueMeta, + keyName: 'head', + attribute: 'data-n-head', + ssrAttribute: 'data-n-head-ssr', + tagIDKeyName: 'hid' + } + } + + async render (renderContext) { + const { url = '/', req = {} } = renderContext + const modernMode = this.options.modern + const modern = (modernMode && this.options.target === TARGETS.static) || isModernRequest(req, modernMode) + const cacheKey = `${modern ? 'modern:' : 'legacy:'}${url}` + let meta = this.cache.get(cacheKey) + + if (meta) { + // Return a copy of the content, so that future + // modifications do not effect the data in cache + return cloneDeep(meta) + } + + meta = { + HTML_ATTRS: '', + HEAD_ATTRS: '', + BODY_ATTRS: '', + HEAD: '', + BODY_SCRIPTS_PREPEND: '', + BODY_SCRIPTS: '' + } + + if (this.options.features.meta) { + // Get vue-meta context + let head + if (typeof this.options.head === 'function') { + head = this.options.head() + } else { + head = cloneDeep(this.options.head) + } + + const m = VueMeta.generate(head || {}, this.vueMetaConfig) + + // HTML_ATTRS + meta.HTML_ATTRS = m.htmlAttrs.text() + + // HEAD_ATTRS + meta.HEAD_ATTRS = m.headAttrs.text() + + // BODY_ATTRS + meta.BODY_ATTRS = m.bodyAttrs.text() + + // HEAD tags + meta.HEAD = + m.title.text() + + m.meta.text() + + m.link.text() + + m.style.text() + + m.script.text() + + m.noscript.text() + + // Add meta if router base specified + if (this.options._routerBaseSpecified) { + meta.HEAD += `` + } + + // BODY_SCRIPTS (PREPEND) + meta.BODY_SCRIPTS_PREPEND = + m.meta.text({ pbody: true }) + + m.link.text({ pbody: true }) + + m.style.text({ pbody: true }) + + m.script.text({ pbody: true }) + + m.noscript.text({ pbody: true }) + + // BODY_SCRIPTS (APPEND) + meta.BODY_SCRIPTS = + m.meta.text({ body: true }) + + m.link.text({ body: true }) + + m.style.text({ body: true }) + + m.script.text({ body: true }) + + m.noscript.text({ body: true }) + } + + // Resources Hints + meta.resourceHints = '' + + const { resources: { modernManifest, clientManifest } } = this.serverContext + const manifest = modern ? modernManifest : clientManifest + + const { shouldPreload, shouldPrefetch } = this.options.render.bundleRenderer + + if (this.options.render.resourceHints && manifest) { + const publicPath = manifest.publicPath || '/_nuxt/' + + // Preload initial resources + if (Array.isArray(manifest.initial)) { + const { crossorigin } = this.options.render + const cors = `${crossorigin ? ` crossorigin="${crossorigin}"` : ''}` + + meta.preloadFiles = manifest.initial + .map(SPARenderer.normalizeFile) + .filter(({ fileWithoutQuery, asType }) => shouldPreload(fileWithoutQuery, asType)) + .map(file => ({ ...file, modern })) + + meta.resourceHints += meta.preloadFiles + .map(({ file, extension, fileWithoutQuery, asType, modern }) => { + let extra = '' + if (asType === 'font') { + extra = ` type="font/${extension}"${cors ? '' : ' crossorigin'}` + } + const rel = modern && asType === 'script' ? 'modulepreload' : 'preload' + return `` + }) + .join('') + } + + // Prefetch async resources + if (Array.isArray(manifest.async)) { + meta.resourceHints += manifest.async + .map(SPARenderer.normalizeFile) + .filter(({ fileWithoutQuery, asType }) => shouldPrefetch(fileWithoutQuery, asType)) + .map(({ file }) => ``) + .join('') + } + + // Add them to HEAD + if (meta.resourceHints) { + meta.HEAD += meta.resourceHints + } + } + + // Serialize state (runtime config) + let APP = `${meta.BODY_SCRIPTS_PREPEND}
${this.serverContext.resources.loadingHTML}
${meta.BODY_SCRIPTS}` + + APP += `` + + // Prepare template params + const templateParams = { + ...meta, + APP, + ENV: this.options.env + } + + // Call spa:templateParams hook + await this.serverContext.nuxt.callHook('vue-renderer:spa:templateParams', templateParams) + + // Render with SPA template + const html = this.renderTemplate(this.serverContext.resources.spaTemplate, templateParams) + const content = { + html, + preloadFiles: meta.preloadFiles || [] + } + + // Set meta tags inside cache + this.cache.set(cacheKey, content) + + // Return a copy of the content, so that future + // modifications do not effect the data in cache + return cloneDeep(content) + } + + static normalizeFile (file) { + const withoutQuery = file.replace(/\?.*/, '') + const extension = extname(withoutQuery).slice(1) + return { + file, + extension, + fileWithoutQuery: withoutQuery, + asType: SPARenderer.getPreloadType(extension) + } + } + + static getPreloadType (ext) { + if (ext === 'js') { + return 'script' + } else if (ext === 'css') { + return 'style' + } else if (/jpe?g|png|svg|gif|webp|ico/.test(ext)) { + return 'image' + } else if (/woff2?|ttf|otf|eot/.test(ext)) { + return 'font' + } else { + return '' + } + } +} diff --git a/packages/nuxt3/src/vue-renderer/renderers/ssr.ts b/packages/nuxt3/src/vue-renderer/renderers/ssr.ts new file mode 100644 index 0000000000..068aea5e2f --- /dev/null +++ b/packages/nuxt3/src/vue-renderer/renderers/ssr.ts @@ -0,0 +1,292 @@ +import path from 'path' +import crypto from 'crypto' +import { format } from 'util' +import fs from 'fs-extra' +import consola from 'consola' +import { TARGETS, urlJoin } from 'src/utils' +import devalue from '@nuxt/devalue' +import { createBundleRenderer } from 'vue-bundle-renderer' +import BaseRenderer from './base' + +export default class SSRRenderer extends BaseRenderer { + constructor (serverContext) { + super(serverContext) + this.createRenderer() + } + + get rendererOptions () { + const hasModules = fs.existsSync(path.resolve(this.options.rootDir, 'node_modules')) + + return { + vueServerRenderer: require('@vue/server-renderer'), + clientManifest: this.serverContext.resources.clientManifest, + // for globally installed nuxt command, search dependencies in global dir + basedir: hasModules ? this.options.rootDir : __dirname, + ...this.options.render.bundleRenderer + } + } + + renderScripts (renderContext) { + const scripts = renderContext.renderScripts() + const { render: { crossorigin } } = this.options + if (!crossorigin) { + return scripts + } + return scripts.replace( + /` + preloadScripts.push(stateUrl) + } else { + APP += `` + } + + // Page level payload.js (async loaded for CSR) + const payloadPath = urlJoin(url, 'payload.js') + const payloadUrl = urlJoin(routerBase, staticAssetsBase, payloadPath) + const routePath = (url.replace(/\/+$/, '') || '/').split('?')[0] // remove trailing slah and query params + const payloadScript = `__NUXT_JSONP__("${routePath}", ${devalue({ data, fetch, mutations })});` + staticAssets.push({ path: payloadPath, src: payloadScript }) + preloadScripts.push(payloadUrl) + + // Preload links + for (const href of preloadScripts) { + HEAD += `` + } + } 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 += `` + } + } + + // Calculate CSP hashes + const cspScriptSrcHashes = [] + if (csp) { + if (shouldHashCspScriptSrc) { + for (const script of inlineScripts) { + const hash = crypto.createHash(csp.hashAlgorithm) + hash.update(script) + cspScriptSrcHashes.push(`'${csp.hashAlgorithm}-${hash.digest('base64')}'`) + } + } + + // Call ssr:csp hook + await this.serverContext.nuxt.callHook('vue-renderer:ssr:csp', cspScriptSrcHashes) + + // Add csp meta tags + if (csp.addMeta) { + HEAD += `` + } + } + + // Prepend scripts + if (shouldInjectScripts) { + APP += this.renderScripts(renderContext) + } + + if (meta) { + const appendInjectorOptions = { body: true } + + // Append body scripts + APP += meta.meta.text(appendInjectorOptions) + APP += meta.link.text(appendInjectorOptions) + APP += meta.style.text(appendInjectorOptions) + APP += meta.script.text(appendInjectorOptions) + APP += meta.noscript.text(appendInjectorOptions) + } + + // Template params + const templateParams = { + HTML_ATTRS: meta ? meta.htmlAttrs.text(renderContext.nuxt.serverRendered /* addSrrAttribute */) : '', + HEAD_ATTRS: meta ? meta.headAttrs.text() : '', + BODY_ATTRS: meta ? meta.bodyAttrs.text() : '', + HEAD, + APP, + ENV: this.options.env + } + + // Call ssr:templateParams hook + await this.serverContext.nuxt.callHook('vue-renderer:ssr:templateParams', templateParams, renderContext) + + // Render with SSR template + const html = this.renderTemplate(this.serverContext.resources.ssrTemplate, templateParams) + + let preloadFiles + if (this.options.render.http2.push) { + preloadFiles = this.getPreloadFiles(renderContext) + } + + return { + html, + cspScriptSrcHashes, + preloadFiles, + error: renderContext.nuxt.error, + redirected: renderContext.redirected + } + } +} diff --git a/packages/nuxt3/src/webpack/builder.ts b/packages/nuxt3/src/webpack/builder.ts new file mode 100644 index 0000000000..666ccdd6e9 --- /dev/null +++ b/packages/nuxt3/src/webpack/builder.ts @@ -0,0 +1,256 @@ +import path from 'path' +import pify from 'pify' +import webpack from 'webpack' +import Glob from 'glob' +import webpackDevMiddleware from 'webpack-dev-middleware' +import webpackHotMiddleware from 'webpack-hot-middleware' +import consola from 'consola' + +import { TARGETS, parallel, sequence, wrapArray, isModernRequest } from 'src/utils' +import { createMFS } from './utils/mfs' + +import * as WebpackConfigs from './config' +import PerfLoader from './utils/perf-loader' + +const glob = pify(Glob) + +export class WebpackBundler { + constructor (buildContext) { + this.buildContext = buildContext + + // Class fields + this.compilers = [] + this.compilersWatching = [] + this.devMiddleware = {} + this.hotMiddleware = {} + + // Bind middleware to self + this.middleware = this.middleware.bind(this) + + // Initialize shared MFS for dev + if (this.buildContext.options.dev) { + this.mfs = createMFS() + } + } + + getWebpackConfig (name) { + const Config = WebpackConfigs[name.toLowerCase()] // eslint-disable-line import/namespace + if (!Config) { + throw new Error(`Unsupported webpack config ${name}`) + } + const config = new Config(this) + return config.config() + } + + async build () { + const { options } = this.buildContext + + const webpackConfigs = [ + this.getWebpackConfig('Client') + ] + + if (options.modern) { + webpackConfigs.push(this.getWebpackConfig('Modern')) + } + + if (options.build.ssr) { + webpackConfigs.push(this.getWebpackConfig('Server')) + } + + await this.buildContext.nuxt.callHook('webpack:config', webpackConfigs) + + // Check styleResource existence + const { styleResources } = this.buildContext.options.build + if (styleResources && Object.keys(styleResources).length) { + consola.warn( + 'Using styleResources without the @nuxtjs/style-resources is not suggested and can lead to severe performance issues.', + 'Please use https://github.com/nuxt-community/style-resources-module' + ) + for (const ext of Object.keys(styleResources)) { + await Promise.all(wrapArray(styleResources[ext]).map(async (p) => { + const styleResourceFiles = await glob(path.resolve(this.buildContext.options.rootDir, p)) + + if (!styleResourceFiles || styleResourceFiles.length === 0) { + throw new Error(`Style Resource not found: ${p}`) + } + })) + } + } + + // Configure compilers + this.compilers = webpackConfigs.map((config) => { + const compiler = webpack(config) + + // In dev, write files in memory FS + if (options.dev) { + compiler.outputFileSystem = this.mfs + } + + return compiler + }) + + // Warm up perfLoader before build + if (options.build.parallel) { + consola.info('Warming up worker pools') + PerfLoader.warmupAll({ dev: options.dev }) + consola.success('Worker pools ready') + } + + // Start Builds + const runner = options.dev ? parallel : sequence + + await runner(this.compilers, compiler => this.webpackCompile(compiler)) + } + + async webpackCompile (compiler) { + const { name } = compiler.options + const { nuxt, options } = this.buildContext + + await nuxt.callHook('build:compile', { name, compiler }) + + // Load renderer resources after build + compiler.hooks.done.tap('load-resources', async (stats) => { + await nuxt.callHook('build:compiled', { + name, + compiler, + stats + }) + + // Reload renderer + await nuxt.callHook('build:resources', this.mfs) + }) + + // --- Dev Build --- + if (options.dev) { + // Client buiild + if (['client', 'modern'].includes(name)) { + return new Promise((resolve, reject) => { + compiler.hooks.done.tap('nuxt-dev', () => { resolve() }) + compiler.hooks.failed.tap('nuxt-errorlog', (err) => { reject(err) }) + // Start watch + this.webpackDev(compiler) + }) + } + + // Server, build and watch for changes + return new Promise((resolve, reject) => { + const watching = compiler.watch(options.watchers.webpack, (err) => { + if (err) { + return reject(err) + } + resolve() + }) + + watching.close = pify(watching.close) + this.compilersWatching.push(watching) + }) + } + + // --- Production Build --- + compiler.run = pify(compiler.run) + const stats = await compiler.run() + + if (stats.hasErrors()) { + // non-quiet mode: errors will be printed by webpack itself + const error = new Error('Nuxt build error') + if (options.build.quiet === true) { + error.stack = stats.toString('errors-only') + } + throw error + } + + // Await for renderer to load resources (programmatic, tests and generate) + await nuxt.callHook('build:resources') + } + + async webpackDev (compiler) { + consola.debug('Creating webpack middleware...') + + const { name } = compiler.options + const buildOptions = this.buildContext.options.build + const { client, ...hotMiddlewareOptions } = buildOptions.hotMiddleware || {} + + // Create webpack dev middleware + this.devMiddleware[name] = pify( + webpackDevMiddleware( + compiler, { + publicPath: buildOptions.publicPath, + stats: false, + logLevel: 'silent', + watchOptions: this.buildContext.options.watchers.webpack, + fs: this.mfs, + ...buildOptions.devMiddleware + }) + ) + + this.devMiddleware[name].close = pify(this.devMiddleware[name].close) + + this.compilersWatching.push(this.devMiddleware[name].context.watching) + + this.hotMiddleware[name] = pify( + webpackHotMiddleware( + compiler, { + log: false, + heartbeat: 10000, + path: `/__webpack_hmr/${name}`, + ...hotMiddlewareOptions + }) + ) + + // Register devMiddleware on server + await this.buildContext.nuxt.callHook('server:devMiddleware', this.middleware) + } + + async middleware (req, res, next) { + const name = isModernRequest(req, this.buildContext.options.modern) ? 'modern' : 'client' + + if (this.devMiddleware && this.devMiddleware[name]) { + await this.devMiddleware[name](req, res) + } + + if (this.hotMiddleware && this.hotMiddleware[name]) { + await this.hotMiddleware[name](req, res) + } + + next() + } + + async unwatch () { + await Promise.all(this.compilersWatching.map(watching => watching.close())) + } + + async close () { + if (this.__closed) { + return + } + this.__closed = true + + // Unwatch + await this.unwatch() + + // Stop webpack middleware + for (const devMiddleware of Object.values(this.devMiddleware)) { + await devMiddleware.close() + } + + for (const compiler of this.compilers) { + compiler.close() + } + + // Cleanup MFS + if (this.mfs) { + delete this.mfs.data + delete this.mfs + } + + // Cleanup more resources + delete this.compilers + delete this.compilersWatching + delete this.devMiddleware + delete this.hotMiddleware + } + + forGenerate () { + this.buildContext.target = TARGETS.static + } +} diff --git a/packages/nuxt3/src/webpack/config/base.ts b/packages/nuxt3/src/webpack/config/base.ts new file mode 100644 index 0000000000..28a2f2304a --- /dev/null +++ b/packages/nuxt3/src/webpack/config/base.ts @@ -0,0 +1,515 @@ +import path from 'path' +import consola from 'consola' +import TimeFixPlugin from 'time-fix-plugin' +import cloneDeep from 'lodash/cloneDeep' +import escapeRegExp from 'lodash/escapeRegExp' +import VueLoaderPlugin from 'vue-loader/dist/pluginWebpack5' +import ExtractCssChunksPlugin from 'extract-css-chunks-webpack-plugin' +import TerserWebpackPlugin from 'terser-webpack-plugin' +import WebpackBar from 'webpackbar' +import env from 'std-env' +import semver from 'semver' +import { TARGETS, isUrl, urlJoin, getPKG } from 'src/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 { + constructor (builder) { + this.builder = builder + this.buildContext = builder.buildContext + } + + get colors () { + return { + client: 'green', + server: 'orange', + modern: 'blue' + } + } + + get devtool () { + return false + } + + get nuxtEnv () { + return { + isDev: this.dev, + isServer: this.isServer, + isClient: !this.isServer, + isModern: Boolean(this.isModern), + isLegacy: Boolean(!this.isModern) + } + } + + get mode () { + return this.dev ? 'development' : 'production' + } + + get target () { + return this.buildContext.target + } + + get dev () { + return this.buildContext.options.dev + } + + get loaders () { + if (!this._loaders) { + this._loaders = cloneDeep(this.buildContext.buildOptions.loaders) + // sass-loader<8 support (#6460) + const sassLoaderPKG = getPKG('sass-loader') + if (sassLoaderPKG && semver.lt(sassLoaderPKG.version, '8.0.0')) { + const { sass } = this._loaders + sass.indentedSyntax = sass.sassOptions.indentedSyntax + delete sass.sassOptions.indentedSyntax + } + } + return this._loaders + } + + get modulesToTranspile () { + return [ + /\.vue\.js/i, // include SFCs in node_modules + /consola\/src/, + ...this.normalizeTranspile({ pathNormalize: true }) + ] + } + + normalizeTranspile ({ pathNormalize = false } = {}) { + const transpile = [] + for (let pattern of this.buildContext.buildOptions.transpile) { + if (typeof pattern === 'function') { + pattern = pattern(this.nuxtEnv) + } + if (pattern instanceof RegExp) { + transpile.push(pattern) + } else if (typeof pattern === 'string') { + const posixModule = pattern.replace(/\\/g, '/') + transpile.push(new RegExp(escapeRegExp( + pathNormalize ? path.normalize(posixModule) : posixModule + ))) + } + } + return transpile + } + + getBabelOptions () { + const envName = this.name + const options = { + ...this.buildContext.buildOptions.babel, + envName + } + + if (options.configFile || options.babelrc) { + return options + } + + if (typeof options.plugins === 'function') { + options.plugins = options.plugins( + { + envName, + ...this.nuxtEnv + } + ) + } + + const defaultPreset = [require.resolve('../../babel-preset-app'), {}] + + if (typeof options.presets === 'function') { + options.presets = options.presets( + { + envName, + ...this.nuxtEnv + }, + defaultPreset + ) + } + + if (!options.presets) { + options.presets = [defaultPreset] + } + + return options + } + + getFileName (key) { + let fileName = this.buildContext.buildOptions.filenames[key] + if (typeof fileName === 'function') { + fileName = fileName(this.nuxtEnv) + } + + if (typeof fileName === 'string' && this.dev) { + const hash = /\[(chunkhash|contenthash|hash)(?::(\d+))?]/.exec(fileName) + if (hash) { + consola.warn(`Notice: Please do not use ${hash[1]} in dev mode to prevent memory leak`) + } + } + return fileName + } + + env () { + const env = { + 'process.env.NODE_ENV': JSON.stringify(this.mode), + 'process.mode': JSON.stringify(this.mode), + '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') + env['typeof window'] = JSON.stringify(!this.isServer ? 'object' : 'undefined') + env['typeof document'] = JSON.stringify(!this.isServer ? 'object' : 'undefined') + } + + Object.entries(this.buildContext.options.env).forEach(([key, value]) => { + env['process.env.' + key] = + ['boolean', 'number'].includes(typeof value) + ? value + : JSON.stringify(value) + }) + return env + } + + output () { + const { + options: { buildDir, router }, + buildOptions: { publicPath } + } = this.buildContext + return { + path: path.resolve(buildDir, 'dist', this.isServer ? 'server' : 'client'), + filename: this.getFileName('app'), + chunkFilename: this.getFileName('chunk'), + publicPath: isUrl(publicPath) ? publicPath : urlJoin(router.base, publicPath) + } + } + + cache () { + if (!this.buildContext.buildOptions.cache) { + return false + } + + return { + type: 'filesystem', + cacheDirectory: path.resolve('node_modules/.cache/@nuxt/webpack/'), + buildDependencies: { + config: [...this.buildContext.options._nuxtConfigFiles] + }, + ...this.buildContext.buildOptions.cache, + name: this.name + } + } + + optimization () { + const optimization = cloneDeep(this.buildContext.buildOptions.optimization) + + if (optimization.minimize && optimization.minimizer === undefined) { + optimization.minimizer = this.minimizer() + } + + return optimization + } + + resolve () { + // Prioritize nested node_modules in webpack search path (#2558) + const webpackModulesDir = ['node_modules'].concat(this.buildContext.options.modulesDir) + + return { + resolve: { + extensions: ['.wasm', '.mjs', '.js', '.json', '.vue', '.jsx'], + alias: this.alias(), + modules: webpackModulesDir + }, + resolveLoader: { + modules: webpackModulesDir + } + } + } + + minimizer () { + const minimizer = [] + const { terser, cache } = this.buildContext.buildOptions + + // https://github.com/webpack-contrib/terser-webpack-plugin + if (terser) { + minimizer.push( + new TerserWebpackPlugin(Object.assign({ + cache, + extractComments: { + condition: 'some', + filename: 'LICENSES' + }, + terserOptions: { + compress: { + ecma: this.isModern ? 6 : undefined + }, + mangle: { + reserved: reservedVueTags + } + } + }, terser)) + ) + } + + return minimizer + } + + alias () { + return { + ...this.buildContext.options.alias, + 'nuxt-app': path.dirname(require.resolve('../../vue-app')), + 'nuxt-build': this.buildContext.options.buildDir, + 'vue-meta': require.resolve(`vue-meta${this.isServer ? '' : '/dist/vue-meta.esm.browser.js'}`) + } + } + + rules () { + const perfLoader = new PerfLoader(this.name, this.buildContext) + const styleLoader = new StyleLoader( + this.buildContext, + { isServer: this.isServer, perfLoader } + ) + + const babelLoader = { + loader: require.resolve('babel-loader'), + options: this.getBabelOptions() + } + + return [ + { + test: /\.vue$/i, + loader: 'vue-loader', + options: this.loaders.vue + }, + { + test: /\.pug$/i, + oneOf: [ + { + resourceQuery: /^\?vue/i, + use: [{ + loader: 'pug-plain-loader', + options: this.loaders.pugPlain + }] + }, + { + use: [ + 'raw-loader', + { + loader: 'pug-plain-loader', + options: this.loaders.pugPlain + } + ] + } + ] + }, + { + test: /\.m?jsx?$/i, + exclude: (file) => { + file = file.split('node_modules', 2)[1] + + // not exclude files outside node_modules + if (!file) { + return false + } + + // item in transpile can be string or regex object + return !this.modulesToTranspile.some(module => module.test(file)) + }, + use: perfLoader.js().concat(babelLoader) + }, + { + test: /\.css$/i, + oneOf: styleLoader.apply('css') + }, + { + test: /\.p(ost)?css$/i, + oneOf: styleLoader.apply('postcss') + }, + { + test: /\.less$/i, + oneOf: styleLoader.apply('less', { + loader: 'less-loader', + options: this.loaders.less + }) + }, + { + test: /\.sass$/i, + oneOf: styleLoader.apply('sass', { + loader: 'sass-loader', + options: this.loaders.sass + }) + }, + { + test: /\.scss$/i, + oneOf: styleLoader.apply('scss', { + loader: 'sass-loader', + options: this.loaders.scss + }) + }, + { + test: /\.styl(us)?$/i, + oneOf: styleLoader.apply('stylus', { + loader: 'stylus-loader', + options: this.loaders.stylus + }) + }, + { + test: /\.(png|jpe?g|gif|svg|webp)$/i, + use: [{ + loader: 'url-loader', + options: Object.assign( + this.loaders.imgUrl, + { name: this.getFileName('img') } + ) + }] + }, + { + test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/i, + use: [{ + loader: 'url-loader', + options: Object.assign( + this.loaders.fontUrl, + { name: this.getFileName('font') } + ) + }] + }, + { + test: /\.(webm|mp4|ogv)$/i, + use: [{ + loader: 'file-loader', + options: Object.assign( + this.loaders.file, + { name: this.getFileName('video') } + ) + }] + } + ] + } + + plugins () { + const plugins = [] + const { nuxt, buildOptions } = this.buildContext + + // Add timefix-plugin before others plugins + if (this.dev) { + plugins.push(new TimeFixPlugin()) + } + + // CSS extraction) + if (buildOptions.extractCSS) { + plugins.push(new ExtractCssChunksPlugin(Object.assign({ + filename: this.getFileName('css'), + chunkFilename: this.getFileName('css') + }, buildOptions.extractCSS))) + } + + plugins.push(new VueLoaderPlugin()) + + plugins.push(...(buildOptions.plugins || [])) + + plugins.push(new WarningIgnorePlugin(this.warningIgnoreFilter())) + + // Build progress indicator + plugins.push(new WebpackBar({ + name: this.name, + color: this.colors[this.name], + reporters: [ + 'basic', + 'fancy', + 'profile', + 'stats' + ], + basic: !buildOptions.quiet && env.minimalCLI, + fancy: !buildOptions.quiet && !env.minimalCLI, + profile: !buildOptions.quiet && buildOptions.profile, + stats: !buildOptions.quiet && !this.dev && buildOptions.stats, + reporter: { + change: (_, { shortPath }) => { + if (!this.isServer) { + nuxt.callHook('bundler:change', shortPath) + } + }, + done: (buildContext) => { + if (buildContext.hasErrors) { + nuxt.callHook('bundler:error') + } + }, + allDone: () => { + nuxt.callHook('bundler:done') + }, + progress ({ statesArray }) { + nuxt.callHook('bundler:progress', statesArray) + } + } + })) + + // CSS extraction + if (this.buildContext.buildOptions.extractCSS) { + plugins.push(new ExtractCssChunksPlugin(Object.assign({ + filename: this.getFileName('css'), + chunkFilename: this.getFileName('css'), + // TODO: https://github.com/faceyspacey/extract-css-chunks-webpack-plugin/issues/132 + reloadAll: true + }, this.buildContext.buildOptions.extractCSS))) + } + + return plugins + } + + warningIgnoreFilter () { + const filters = [ + // Hide warnings about plugins without a default export (#1179) + warn => warn.name === 'ModuleDependencyWarning' && + warn.message.includes('export \'default\'') && + warn.message.includes('nuxt_plugin_'), + ...(this.buildContext.buildOptions.warningIgnoreFilters || []) + ] + + return warn => !filters.some(ignoreFilter => ignoreFilter(warn)) + } + + extendConfig (config) { + const { extend } = this.buildContext.buildOptions + if (typeof extend === 'function') { + const extendedConfig = extend.call( + this.builder, config, { loaders: this.loaders, ...this.nuxtEnv } + ) || config + + const pragma = /@|#/ + const { devtool } = extendedConfig + if (typeof devtool === 'string' && pragma.test(devtool)) { + extendedConfig.devtool = devtool.replace(pragma, '') + consola.warn(`devtool has been normalized to ${extendedConfig.devtool} as webpack documented value`) + } + + return extendedConfig + } + return config + } + + config () { + const config = { + name: this.name, + mode: this.mode, + devtool: this.devtool, + cache: this.cache(), + optimization: this.optimization(), + output: this.output(), + performance: { + maxEntrypointSize: 1000 * 1024, + hints: this.dev ? false : 'warning' + }, + module: { + rules: this.rules() + }, + plugins: this.plugins(), + ...this.resolve() + } + + // Clone deep avoid leaking config between Client and Server + const extendedConfig = cloneDeep(this.extendConfig(config)) + + return extendedConfig + } +} diff --git a/packages/nuxt3/src/webpack/config/client.ts b/packages/nuxt3/src/webpack/config/client.ts new file mode 100644 index 0000000000..a08c3ad166 --- /dev/null +++ b/packages/nuxt3/src/webpack/config/client.ts @@ -0,0 +1,235 @@ +import path from 'path' +import querystring from 'querystring' +import webpack from 'webpack' +import HTMLPlugin from 'html-webpack-plugin' +import BundleAnalyzer from 'webpack-bundle-analyzer' +import OptimizeCSSAssetsPlugin from 'optimize-css-assets-webpack-plugin' +import FriendlyErrorsWebpackPlugin from '@nuxt/friendly-errors-webpack-plugin' + +import CorsPlugin from '../plugins/vue/cors' +import ModernModePlugin from '../plugins/vue/modern' +import VueSSRClientPlugin from '../plugins/vue/client' +import WebpackBaseConfig from './base' + +export default class WebpackClientConfig extends WebpackBaseConfig { + constructor (builder) { + super(builder) + this.name = 'client' + this.isServer = false + this.isModern = false + } + + get devtool () { + if (!this.dev) { + return false + } + const scriptPolicy = this.getCspScriptPolicy() + const noUnsafeEval = scriptPolicy && !scriptPolicy.includes('\'unsafe-eval\'') + return noUnsafeEval + ? 'cheap-module-source-map' + : 'eval-cheap-module-source-map' + } + + getCspScriptPolicy () { + const { csp } = this.buildContext.options.render + if (csp) { + const { policies = {} } = csp + return policies['script-src'] || policies['default-src'] || [] + } + } + + env () { + return Object.assign( + super.env(), + { + 'process.env.VUE_ENV': JSON.stringify('client'), + 'process.browser': true, + 'process.client': true, + 'process.server': false, + 'process.modern': false + } + ) + } + + optimization () { + const optimization = super.optimization() + const { splitChunks } = optimization + const { cacheGroups } = splitChunks + + // Small, known and common modules which are usually used project-wise + // Sum of them may not be more than 244 KiB + if ( + this.buildContext.buildOptions.splitChunks.commons === true && + cacheGroups.commons === undefined + ) { + cacheGroups.commons = { + test: /node_modules[\\/](vue|vue-loader|vue-router|vuex|vue-meta|core-js|@babel\/runtime|axios|webpack|setimmediate|timers-browserify|process|regenerator-runtime|cookie|js-cookie|is-buffer|dotprop|nuxt\.js)[\\/]/, + chunks: 'all', + priority: 10, + name: 'commons', + automaticNameDelimiter: '/' + } + } + + if (!this.dev && cacheGroups.default && cacheGroups.default.name === undefined) { + cacheGroups.default.name = (_module, chunks) => { + // Use default name for single chunks + if (chunks.length === 1) { + return chunks[0].name || '' + } + // Use compact name for concatinated modules + return 'commons/' + chunks.filter(c => c.name).map(c => + c.name.replace(/\//g, '.').replace(/_/g, '').replace('pages.', '') + ).join('~') + } + } + + return optimization + } + + minimizer () { + const minimizer = super.minimizer() + const { optimizeCSS } = this.buildContext.buildOptions + + // https://github.com/NMFR/optimize-css-assets-webpack-plugin + // https://github.com/webpack-contrib/mini-css-extract-plugin#minimizing-for-production + // TODO: Remove OptimizeCSSAssetsPlugin when upgrading to webpack 5 + if (optimizeCSS) { + minimizer.push(new OptimizeCSSAssetsPlugin(Object.assign({}, optimizeCSS))) + } + + return minimizer + } + + alias () { + const aliases = super.alias() + + for (const p of this.buildContext.plugins) { + if (!aliases[p.name]) { + // Do not load server-side plugins on client-side + aliases[p.name] = p.mode === 'server' ? './empty.js' : p.src + } + } + + return aliases + } + + plugins () { + const plugins = super.plugins() + const { buildOptions, options: { appTemplatePath, buildDir, modern, render } } = this.buildContext + + // Generate output HTML for SSR + if (buildOptions.ssr) { + plugins.push( + new HTMLPlugin({ + filename: '../server/index.ssr.html', + template: appTemplatePath, + minify: buildOptions.html.minify, + inject: false // Resources will be injected using bundleRenderer + }) + ) + } + + plugins.push( + new HTMLPlugin({ + filename: '../server/index.spa.html', + template: appTemplatePath, + minify: buildOptions.html.minify, + inject: true + }), + new VueSSRClientPlugin({ + filename: `../server/${this.name}.manifest.json` + }), + new webpack.DefinePlugin(this.env()) + ) + + if (this.dev) { + // TODO: webpackHotUpdate is not defined: https://github.com/webpack/webpack/issues/6693 + plugins.push(new webpack.HotModuleReplacementPlugin()) + } + + // Webpack Bundle Analyzer + // https://github.com/webpack-contrib/webpack-bundle-analyzer + if (!this.dev && buildOptions.analyze) { + const statsDir = path.resolve(buildDir, 'stats') + + plugins.push(new BundleAnalyzer.BundleAnalyzerPlugin(Object.assign({ + analyzerMode: 'static', + defaultSizes: 'gzip', + generateStatsFile: true, + openAnalyzer: !buildOptions.quiet, + reportFilename: path.resolve(statsDir, `${this.name}.html`), + statsFilename: path.resolve(statsDir, `${this.name}.json`) + }, buildOptions.analyze))) + } + + if (modern) { + const scriptPolicy = this.getCspScriptPolicy() + const noUnsafeInline = scriptPolicy && !scriptPolicy.includes('\'unsafe-inline\'') + plugins.push(new ModernModePlugin({ + targetDir: path.resolve(buildDir, 'dist', 'client'), + isModernBuild: this.isModern, + noUnsafeInline + })) + } + + if (render.crossorigin) { + plugins.push(new CorsPlugin({ + crossorigin: render.crossorigin + })) + } + + return plugins + } + + config () { + const config = super.config() + const { + options: { router, buildDir }, + buildOptions: { hotMiddleware, quiet, friendlyErrors } + } = this.buildContext + + const { client = {} } = hotMiddleware || {} + const { ansiColors, overlayStyles, ...options } = client + + const hotMiddlewareClientOptions = { + reload: true, + timeout: 30000, + ansiColors: JSON.stringify(ansiColors), + overlayStyles: JSON.stringify(overlayStyles), + path: `${router.base}/__webpack_hmr/${this.name}`.replace(/\/\//g, '/'), + ...options, + name: this.name + } + + const hotMiddlewareClientOptionsStr = querystring.stringify(hotMiddlewareClientOptions) + + // Entry points + config.entry = Object.assign({}, config.entry, { + app: [path.resolve(buildDir, 'entry.client.js')] + }) + + // Add HMR support + if (this.dev) { + config.entry.app.unshift( + // https://github.com/webpack-contrib/webpack-hot-middleware/issues/53#issuecomment-162823945 + 'eventsource-polyfill', + // https://github.com/glenjamin/webpack-hot-middleware#config + `webpack-hot-middleware/client?${hotMiddlewareClientOptionsStr}` + ) + } + + // Add friendly error plugin + if (this.dev && !quiet && friendlyErrors) { + config.plugins.push( + new FriendlyErrorsWebpackPlugin({ + clearConsole: false, + reporter: 'consola', + logLevel: 'WARNING' + }) + ) + } + + return config + } +} diff --git a/packages/nuxt3/src/webpack/config/index.ts b/packages/nuxt3/src/webpack/config/index.ts new file mode 100644 index 0000000000..e22da970a9 --- /dev/null +++ b/packages/nuxt3/src/webpack/config/index.ts @@ -0,0 +1,3 @@ +export { default as client } from './client' +export { default as modern } from './modern' +export { default as server } from './server' diff --git a/packages/nuxt3/src/webpack/config/modern.ts b/packages/nuxt3/src/webpack/config/modern.ts new file mode 100644 index 0000000000..4c97013439 --- /dev/null +++ b/packages/nuxt3/src/webpack/config/modern.ts @@ -0,0 +1,15 @@ +import WebpackClientConfig from './client' + +export default class WebpackModernConfig extends WebpackClientConfig { + constructor (...args) { + super(...args) + this.name = 'modern' + this.isModern = true + } + + env () { + return Object.assign(super.env(), { + 'process.modern': true + }) + } +} diff --git a/packages/nuxt3/src/webpack/config/server.ts b/packages/nuxt3/src/webpack/config/server.ts new file mode 100644 index 0000000000..4c1dd27267 --- /dev/null +++ b/packages/nuxt3/src/webpack/config/server.ts @@ -0,0 +1,161 @@ +import path from 'path' +import fs from 'fs' +import { DefinePlugin, ProvidePlugin } from 'webpack' +import FriendlyErrorsWebpackPlugin from '@nuxt/friendly-errors-webpack-plugin' + +// TODO: remove when webpack-node-externals support webpack5 +// import nodeExternals from 'webpack-node-externals' +import nodeExternals from '../plugins/externals' +import VueSSRServerPlugin from '../plugins/vue/server' + +import WebpackBaseConfig from './base' + +const nativeFileExtensions = [ + '.json', + '.js' +] + +export default class WebpackServerConfig extends WebpackBaseConfig { + constructor (...args) { + super(...args) + this.name = 'server' + this.isServer = true + } + + get devtool () { + return 'cheap-module-source-map' + } + + get externalsWhitelist () { + return [ + this.isNonNativeImport.bind(this), + ...this.normalizeTranspile() + ] + } + + /** + * files *not* ending on js|json should be processed by webpack + * + * this might generate false-positives for imports like + * - "someFile.umd" (actually requiring someFile.umd.js) + * - "some.folder" (some.folder being a directory containing a package.json) + */ + isNonNativeImport (modulePath) { + const extname = path.extname(modulePath) + return extname !== '' && !nativeFileExtensions.includes(extname) + } + + env () { + return Object.assign( + super.env(), + { + 'process.env.VUE_ENV': JSON.stringify('server'), + 'process.browser': false, + 'process.client': false, + 'process.server': true, + 'process.modern': false + } + ) + } + + optimization () { + const { _minifyServer } = this.buildContext.buildOptions + + return { + splitChunks: false, + minimizer: _minifyServer ? this.minimizer() : [] + } + } + + resolve () { + const resolveConfig = super.resolve() + + resolveConfig.resolve.mainFields = ['main', 'module'] + + return resolveConfig + } + + alias () { + const aliases = super.alias() + + for (const p of this.buildContext.plugins) { + if (!aliases[p.name]) { + // Do not load client-side plugins on server-side + aliases[p.name] = p.mode === 'client' ? './empty.js' : p.src + } + } + + return aliases + } + + plugins () { + const plugins = super.plugins() + plugins.push( + new VueSSRServerPlugin({ filename: `${this.name}.manifest.json` }), + new DefinePlugin(this.env()) + ) + + const { serverURLPolyfill } = this.buildContext.options.build + + if (serverURLPolyfill) { + plugins.push(new ProvidePlugin({ + URL: [serverURLPolyfill, 'URL'], + URLSearchParams: [serverURLPolyfill, 'URLSearchParams'] + })) + } + + return plugins + } + + config () { + const config = super.config() + + Object.assign(config, { + target: 'node', + node: false, + entry: Object.assign({}, config.entry, { + app: [path.resolve(this.buildContext.options.buildDir, 'entry.server.js')] + }), + output: Object.assign({}, config.output, { + filename: 'server.js', + chunkFilename: '[name].js', + libraryTarget: 'commonjs2' + }), + performance: { + hints: false, + maxEntrypointSize: Infinity, + maxAssetSize: Infinity + }, + externals: [].concat(config.externals || []) + }) + + // https://webpack.js.org/configuration/externals/#externals + // https://github.com/liady/webpack-node-externals + // https://vue-loader.vuejs.org/migrating.html#ssr-externals + if (!this.buildContext.buildOptions.standalone) { + this.buildContext.options.modulesDir.forEach((dir) => { + if (fs.existsSync(dir)) { + config.externals.push( + nodeExternals({ + whitelist: this.externalsWhitelist, + modulesDir: dir + }) + ) + } + }) + } + + // Add friendly error plugin + if (this.dev) { + config.plugins.push( + new FriendlyErrorsWebpackPlugin({ + clearConsole: false, + reporter: 'consola', + logLevel: 'WARNING' + }) + ) + } + + return config + } +} diff --git a/packages/nuxt3/src/webpack/index.ts b/packages/nuxt3/src/webpack/index.ts new file mode 100644 index 0000000000..80ab709001 --- /dev/null +++ b/packages/nuxt3/src/webpack/index.ts @@ -0,0 +1 @@ +export { WebpackBundler as BundleBuilder } from './builder' diff --git a/packages/nuxt3/src/webpack/plugins/externals.ts b/packages/nuxt3/src/webpack/plugins/externals.ts new file mode 100644 index 0000000000..aca06cee7b --- /dev/null +++ b/packages/nuxt3/src/webpack/plugins/externals.ts @@ -0,0 +1,154 @@ +import fs from 'fs' +import path from 'path' + +function contains (arr, val) { + return arr && arr.includes(val) +} + +const atPrefix = new RegExp('^@', 'g') + +function readDir (dirName) { + if (!fs.existsSync(dirName)) { + return [] + } + + try { + return fs + .readdirSync(dirName) + .map(function (module) { + if (atPrefix.test(module)) { + // reset regexp + atPrefix.lastIndex = 0 + try { + return fs + .readdirSync(path.join(dirName, module)) + .map(function (scopedMod) { + return module + '/' + scopedMod + }) + } catch (e) { + return [module] + } + } + return module + }) + .reduce(function (prev, next) { + return prev.concat(next) + }, []) + } catch (e) { + return [] + } +} + +function readFromPackageJson (options) { + if (typeof options !== 'object') { + options = {} + } + // read the file + let packageJson + try { + const fileName = options.fileName || 'package.json' + const packageJsonString = fs.readFileSync( + path.join(process.cwd(), './' + fileName), + 'utf8' + ) + packageJson = JSON.parse(packageJsonString) + } catch (e) { + return [] + } + // sections to search in package.json + let sections = [ + 'dependencies', + 'devDependencies', + 'peerDependencies', + 'optionalDependencies' + ] + if (options.include) { + sections = [].concat(options.include) + } + if (options.exclude) { + sections = sections.filter(function (section) { + return ![].concat(options.exclude).includes(section) + }) + } + // collect dependencies + const deps = {} + sections.forEach(function (section) { + Object.keys(packageJson[section] || {}).forEach(function (dep) { + deps[dep] = true + }) + }) + return Object.keys(deps) +} + +function containsPattern (arr, val) { + return ( + arr && + arr.some(function (pattern) { + if (pattern instanceof RegExp) { + return pattern.test(val) + } else if (typeof pattern === 'function') { + return pattern(val) + } else { + return pattern === val + } + }) + ) +} + +const scopedModuleRegex = new RegExp( + '@[a-zA-Z0-9][\\w-.]+/[a-zA-Z0-9][\\w-.]+([a-zA-Z0-9./]+)?', + 'g' +) + +function getModuleName (request, includeAbsolutePaths) { + let req = request + const delimiter = '/' + + if (includeAbsolutePaths) { + req = req.replace(/^.*?\/node_modules\//, '') + } + // check if scoped module + if (scopedModuleRegex.test(req)) { + // reset regexp + scopedModuleRegex.lastIndex = 0 + return req.split(delimiter, 2).join(delimiter) + } + return req.split(delimiter)[0] +} + +export default function nodeExternals (options) { + options = options || {} + const whitelist = [].concat(options.whitelist || []) + const binaryDirs = [].concat(options.binaryDirs || ['.bin']) + const importType = options.importType || 'commonjs' + const modulesDir = options.modulesDir || 'node_modules' + const modulesFromFile = !!options.modulesFromFile + const includeAbsolutePaths = !!options.includeAbsolutePaths + + // helper function + function isNotBinary (x) { + return !contains(binaryDirs, x) + } + + // create the node modules list + const nodeModules = modulesFromFile + ? readFromPackageJson(options.modulesFromFile) + : readDir(modulesDir).filter(isNotBinary) + + // return an externals function + return function ({ context, request }, callback) { + const moduleName = getModuleName(request, includeAbsolutePaths) + if ( + contains(nodeModules, moduleName) && + !containsPattern(whitelist, request) + ) { + if (typeof importType === 'function') { + return callback(null, importType(request)) + } + // mark this module as external + // https://webpack.js.org/configuration/externals/ + return callback(null, importType + ' ' + request) + } + callback() + } +} diff --git a/packages/nuxt3/src/webpack/plugins/vue/client.ts b/packages/nuxt3/src/webpack/plugins/vue/client.ts new file mode 100644 index 0000000000..6f718747ed --- /dev/null +++ b/packages/nuxt3/src/webpack/plugins/vue/client.ts @@ -0,0 +1,111 @@ +/** + * This file is based on Vue.js (MIT) webpack plugins + * https://github.com/vuejs/vue/blob/dev/src/server/webpack-plugin/client.js + */ + +import hash from 'hash-sum' +import uniq from 'lodash/uniq' + +import { isJS, isCSS } from './util' + +export default class VueSSRClientPlugin { + constructor (options = {}) { + this.options = Object.assign({ + filename: null + }, options) + } + + apply (compiler) { + compiler.hooks.emit.tapAsync('vue-client-plugin', (compilation, cb) => { + const stats = compilation.getStats().toJson() + + const allFiles = uniq(stats.assets + .map(a => a.name)) + + const initialFiles = uniq(Object.keys(stats.entrypoints) + .map(name => stats.entrypoints[name].assets) + .reduce((assets, all) => all.concat(assets), []) + .filter(file => isJS(file) || isCSS(file))) + + const asyncFiles = allFiles + .filter(file => isJS(file) || isCSS(file)) + .filter(file => !initialFiles.includes(file)) + + const assetsMapping = {} + stats.assets + .filter(({ name }) => isJS(name)) + .forEach(({ name, chunkNames }) => { + const componentHash = hash(chunkNames.join('|')) + if (!assetsMapping[componentHash]) { + assetsMapping[componentHash] = [] + } + assetsMapping[componentHash].push(name) + }) + + const manifest = { + publicPath: stats.publicPath, + all: allFiles, + initial: initialFiles, + async: asyncFiles, + modules: { /* [identifier: string]: Array */ }, + assetsMapping + } + + const { entrypoints, namedChunkGroups } = stats + const assetModules = stats.modules.filter(m => m.assets.length) + const fileToIndex = file => manifest.all.indexOf(file) + stats.modules.forEach((m) => { + // Ignore modules duplicated in multiple chunks + if (m.chunks.length === 1) { + const [cid] = m.chunks + const chunk = stats.chunks.find(c => c.id === cid) + if (!chunk || !chunk.files) { + return + } + const id = m.identifier.replace(/\s\w+$/, '') // remove appended hash + const filesSet = new Set(chunk.files.map(fileToIndex)) + + for (const chunkName of chunk.names) { + if (!entrypoints[chunkName]) { + const chunkGroup = namedChunkGroups[chunkName] + if (chunkGroup) { + for (const asset of chunkGroup.assets) { + filesSet.add(fileToIndex(asset)) + } + } + } + } + + const files = Array.from(filesSet) + manifest.modules[hash(id)] = files + + // In production mode, modules may be concatenated by scope hoisting + // Include ConcatenatedModule for not losing module-component mapping + if (Array.isArray(m.modules)) { + for (const concatenatedModule of m.modules) { + const id = hash(concatenatedModule.identifier.replace(/\s\w+$/, '')) + if (!manifest.modules[id]) { + manifest.modules[id] = files + } + } + } + + // Find all asset modules associated with the same chunk + assetModules.forEach((m) => { + if (m.chunks.some(id => id === cid)) { + files.push.apply(files, m.assets.map(fileToIndex)) + } + }) + } + }) + + const src = JSON.stringify(manifest, null, 2) + + compilation.assets[this.options.filename] = { + source: () => src, + size: () => src.length + } + cb() + }) + } +} diff --git a/packages/nuxt3/src/webpack/plugins/vue/cors.ts b/packages/nuxt3/src/webpack/plugins/vue/cors.ts new file mode 100644 index 0000000000..60b680bed9 --- /dev/null +++ b/packages/nuxt3/src/webpack/plugins/vue/cors.ts @@ -0,0 +1,26 @@ +import HtmlWebpackPlugin from 'html-webpack-plugin' + +export default class CorsPlugin { + constructor ({ crossorigin }) { + this.crossorigin = crossorigin + } + + apply (compiler) { + const ID = 'vue-cors-plugin' + compiler.hooks.compilation.tap(ID, (compilation) => { + HtmlWebpackPlugin.getHooks(compilation).alterAssetTagGroups.tap(ID, (data) => { + if (!this.crossorigin) { + return + } + [...data.headTags, ...data.bodyTags].forEach((tag) => { + if (['script', 'link'].includes(tag.tagName)) { + if (tag.attributes) { + tag.attributes.crossorigin = this.crossorigin + } + } + }) + return data + }) + }) + } +} diff --git a/packages/nuxt3/src/webpack/plugins/vue/modern.ts b/packages/nuxt3/src/webpack/plugins/vue/modern.ts new file mode 100644 index 0000000000..855380f7ba --- /dev/null +++ b/packages/nuxt3/src/webpack/plugins/vue/modern.ts @@ -0,0 +1,130 @@ +/* +* This file is based on @vue/cli-service (MIT) ModernModePlugin +* https://github.com/vuejs/vue-cli/blob/dev/packages/@vue/cli-service/lib/webpack/ModernModePlugin.js +*/ + +import EventEmitter from 'events' +import HtmlWebpackPlugin from 'html-webpack-plugin' +import { safariNoModuleFix } from 'src/utils' + +const assetsMap = {} +const watcher = new EventEmitter() + +export default class ModernModePlugin { + constructor ({ targetDir, isModernBuild, noUnsafeInline }) { + this.targetDir = targetDir + this.isModernBuild = isModernBuild + this.noUnsafeInline = noUnsafeInline + } + + apply (compiler) { + if (!this.isModernBuild) { + this.applyLegacy(compiler) + } else { + this.applyModern(compiler) + } + } + + get assets () { + return assetsMap + } + + set assets ({ name, content }) { + assetsMap[name] = content + watcher.emit(name) + } + + getAssets (name) { + return new Promise((resolve) => { + const asset = this.assets[name] + if (asset) { + return resolve(asset) + } + return watcher.once(name, () => { + const asset = this.assets[name] + return asset && resolve(asset) + }) + }) + } + + applyLegacy (compiler) { + const ID = 'nuxt-legacy-bundle' + compiler.hooks.compilation.tap(ID, (compilation) => { + HtmlWebpackPlugin.getHooks(compilation).alterAssetTagGroups.tap(ID, (data) => { + // get stats, write to disk + this.assets = { + name: data.plugin.options.filename, + content: data.bodyTags + } + return data + }) + }) + } + + applyModern (compiler) { + const ID = 'nuxt-modern-bundle' + compiler.hooks.compilation.tap(ID, (compilation) => { + HtmlWebpackPlugin.getHooks(compilation).alterAssetTagGroups.tapPromise(ID, async (data) => { + // use