diff --git a/.eslintrc.js b/.eslintrc.js index f44a2a5aa5..578878972e 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -16,6 +16,7 @@ module.exports = { rules: { 'no-console': 'error', 'no-debugger': 'error', + 'no-template-curly-in-string': 0, quotes: ['error', 'single', { avoidEscape: true, allowTemplateLiterals: true }] }, overrides: [{ diff --git a/packages/builder/src/builder.js b/packages/builder/src/builder.js index 6939572418..d0dfe27124 100644 --- a/packages/builder/src/builder.js +++ b/packages/builder/src/builder.js @@ -770,6 +770,11 @@ export default class Builder { 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)) diff --git a/packages/cli/src/options/common.js b/packages/cli/src/options/common.js index 3126b5ffe9..dee82f8331 100644 --- a/packages/cli/src/options/common.js +++ b/packages/cli/src/options/common.js @@ -54,5 +54,15 @@ export default { 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/cli/src/utils/config.js b/packages/cli/src/utils/config.js index 40d6bd51f5..ed09dcc74a 100644 --- a/packages/cli/src/utils/config.js +++ b/packages/cli/src/utils/config.js @@ -11,7 +11,11 @@ export async function loadNuxtConfig (argv, configContext) { const options = await _loadNuxtConfig({ rootDir, configFile, - configContext + configContext, + envConfig: { + dotenv: argv.dotenv === 'false' ? false : argv.dotenv, + env: argv.processenv ? process.env : {} + } }) // Nuxt Mode diff --git a/packages/cli/test/unit/__snapshots__/command.test.js.snap b/packages/cli/test/unit/__snapshots__/command.test.js.snap index 79b7771477..f9e7dd5c6c 100644 --- a/packages/cli/test/unit/__snapshots__/command.test.js.snap +++ b/packages/cli/test/unit/__snapshots__/command.test.js.snap @@ -27,6 +27,12 @@ exports[`cli/command builds help text 1`] = ` --version, -v Display the Nuxt version --help, -h Display this message + --no-processenv Disable reading from + process.env and updating it with + dotenv + --dotenv Specify path to + dotenv file (default: .env). Use + false to disable --port, -p Port number on which to start the application --hostname, -H Hostname on which to diff --git a/packages/cli/test/unit/command.test.js b/packages/cli/test/unit/command.test.js index 46f09314c5..d15eec795c 100644 --- a/packages/cli/test/unit/command.test.js +++ b/packages/cli/test/unit/command.test.js @@ -21,8 +21,8 @@ describe('cli/command', () => { const cmd = new Command({ options: allOptions }) const minimistOptions = cmd._getMinimistOptions() - expect(minimistOptions.string.length).toBe(6) - expect(minimistOptions.boolean.length).toBe(5) + expect(minimistOptions.string.length).toBe(7) + expect(minimistOptions.boolean.length).toBe(6) expect(minimistOptions.alias.c).toBe('config-file') expect(minimistOptions.default.c).toBe(common['config-file'].default) }) @@ -71,7 +71,13 @@ describe('cli/command', () => { expect(options.server.port).toBe(3001) expect(consola.fatal).toHaveBeenCalledWith('Provided hostname argument has no value') // hostname check expect(loadConfigSpy).toHaveBeenCalledTimes(1) - expect(loadConfigSpy).toHaveBeenCalledWith(expect.any(Object), { command: 'test', dev: false }) + expect(loadConfigSpy).toHaveBeenCalledWith(expect.any(Object), { + command: 'test', + dev: false, + env: expect.objectContaining({ + NODE_ENV: 'test' + }) + }) loadConfigSpy.mockRestore() }) diff --git a/packages/config/package.json b/packages/config/package.json index ccd2571119..b787ff8298 100644 --- a/packages/config/package.json +++ b/packages/config/package.json @@ -13,6 +13,7 @@ "@nuxt/utils": "2.12.1", "consola": "^2.12.1", "defu": "^2.0.2", + "dotenv": "^8.2.0", "esm": "^3.2.25", "std-env": "^2.2.1" }, diff --git a/packages/config/src/config/_common.js b/packages/config/src/config/_common.js index c813dbc004..5eee2b42ee 100644 --- a/packages/config/src/config/_common.js +++ b/packages/config/src/config/_common.js @@ -77,5 +77,9 @@ export default () => ({ editor: undefined, // Hooks - hooks: null + hooks: null, + + // runtimeConfig + privateRuntimeConfig: {}, + publicRuntimeConfig: {} }) diff --git a/packages/config/src/load.js b/packages/config/src/load.js index 6d013c3a91..b417500fe3 100644 --- a/packages/config/src/load.js +++ b/packages/config/src/load.js @@ -1,12 +1,15 @@ import path from 'path' +import fs from 'fs' import defu from 'defu' import consola from 'consola' +import dotenv from 'dotenv' import { clearRequireCache, scanRequireTree } from '@nuxt/utils' import esm from 'esm' import { defaultNuxtConfigFile } from './config' export async function loadNuxtConfig ({ rootDir = '.', + envConfig = {}, configFile = defaultNuxtConfigFile, configContext = {}, configOverrides = {} @@ -27,6 +30,23 @@ export async function loadNuxtConfig ({ 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) @@ -66,5 +86,85 @@ export async function loadNuxtConfig ({ options.rootDir = rootDir } + // 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) + } + } + } + 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 = {}) { + function getValue (key) { + // Source value 'wins' over target value + return source[key] !== undefined ? source[key] : (target[key] || '') + } + + function interpolate (value) { + const matches = value.match(/(.?\${?(?:[a-zA-Z0-9_:]+)?}?)/g) || [] + return 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/config/test/__snapshots__/options.test.js.snap b/packages/config/test/__snapshots__/options.test.js.snap index b878655729..dcba355b9a 100644 --- a/packages/config/test/__snapshots__/options.test.js.snap +++ b/packages/config/test/__snapshots__/options.test.js.snap @@ -294,6 +294,8 @@ Object { "name": "page", }, "plugins": Array [], + "privateRuntimeConfig": Object {}, + "publicRuntimeConfig": Object {}, "render": Object { "bundleRenderer": Object { "runInNewContext": false, diff --git a/packages/config/test/config/__snapshots__/index.test.js.snap b/packages/config/test/config/__snapshots__/index.test.js.snap index 3cac8bc8e7..5d5acbbbb9 100644 --- a/packages/config/test/config/__snapshots__/index.test.js.snap +++ b/packages/config/test/config/__snapshots__/index.test.js.snap @@ -266,6 +266,8 @@ Object { "name": "page", }, "plugins": Array [], + "privateRuntimeConfig": Object {}, + "publicRuntimeConfig": Object {}, "render": Object { "bundleRenderer": Object { "runInNewContext": undefined, @@ -632,6 +634,8 @@ Object { "name": "page", }, "plugins": Array [], + "privateRuntimeConfig": Object {}, + "publicRuntimeConfig": Object {}, "render": Object { "bundleRenderer": Object { "runInNewContext": undefined, diff --git a/packages/vue-app/template/client.js b/packages/vue-app/template/client.js index 0811de97b9..ca2e125e4f 100644 --- a/packages/vue-app/template/client.js +++ b/packages/vue-app/template/client.js @@ -98,7 +98,7 @@ Vue.config.$nuxt.<%= globals.nuxt %> = true const errorHandler = Vue.config.errorHandler || console.error // Create and mount App -createApp().then(mountApp).catch(errorHandler) +createApp(null, NUXT.config).then(mountApp).catch(errorHandler) <% if (features.transitions) { %> function componentOption (component, key, ...args) { diff --git a/packages/vue-app/template/index.js b/packages/vue-app/template/index.js index d46cd15f0f..15a2868e09 100644 --- a/packages/vue-app/template/index.js +++ b/packages/vue-app/template/index.js @@ -66,7 +66,7 @@ const defaultTransition = <%= %><%= isTest ? '// eslint-disable-line' : '' %> <% } %> -async function createApp (ssrContext) { +async function createApp(ssrContext, config = {}) { const router = await createRouter(ssrContext) <% if (store) { %> @@ -162,8 +162,7 @@ async function createApp (ssrContext) { ssrContext }) - <% if (plugins.length) { %> - const inject = function (key, value) { + function inject(key, value) { if (!key) { throw new Error('inject(key, value) has no key provided') } @@ -199,7 +198,9 @@ async function createApp (ssrContext) { } }) } - <% } %> + + // Inject runtime config as $config + inject('config', config) <% if (store) { %> if (process.client) { diff --git a/packages/vue-app/template/server.js b/packages/vue-app/template/server.js index d8bc6cbd80..a7a13e3638 100644 --- a/packages/vue-app/template/server.js +++ b/packages/vue-app/template/server.js @@ -77,9 +77,10 @@ export default async (ssrContext) => { if (process.static && ssrContext.url) { ssrContext.url = ssrContext.url.split('?')[0] } - + // Public runtime config + ssrContext.nuxt.config = ssrContext.runtimeConfig.public // Create the app definition and the instance (created for each request) - const { app, router<%= (store ? ', store' : '') %> } = await createApp(ssrContext) + const { app, router<%= (store ? ', store' : '') %> } = await createApp(ssrContext, { ...ssrContext.runtimeConfig.public, ...ssrContext.runtimeConfig.private }) const _app = new Vue(app) // Add ssr route path to nuxt context so we can account for page navigation between ssr and csr ssrContext.nuxt.routePath = app.context.route.path diff --git a/packages/vue-renderer/src/renderer.js b/packages/vue-renderer/src/renderer.js index 55610d9c47..2db84bacf4 100644 --- a/packages/vue-renderer/src/renderer.js +++ b/packages/vue-renderer/src/renderer.js @@ -291,6 +291,12 @@ export default class VueRenderer { 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) diff --git a/packages/vue-renderer/src/renderers/spa.js b/packages/vue-renderer/src/renderers/spa.js index ec54112546..25e0f83d37 100644 --- a/packages/vue-renderer/src/renderers/spa.js +++ b/packages/vue-renderer/src/renderers/spa.js @@ -3,6 +3,7 @@ import cloneDeep from 'lodash/cloneDeep' import VueMeta from 'vue-meta' import { createRenderer } from 'vue-server-renderer' import LRU from 'lru-cache' +import devalue from '@nuxt/devalue' import { TARGETS, isModernRequest } from '@nuxt/utils' import BaseRenderer from './base' @@ -148,12 +149,16 @@ export default class SPARenderer extends BaseRenderer { } } + // Serialize state (runtime config) let APP = `${meta.BODY_SCRIPTS_PREPEND}