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}
${this.serverContext.resources.loadingHTML}
${meta.BODY_SCRIPTS}` if (renderContext.staticAssetsBase) { - // Full static, add window.__NUXT_STATIC__ - APP += `` + APP += `` } + APP += `` // Prepare template params const templateParams = { diff --git a/test/dev/runtime-config.test.js b/test/dev/runtime-config.test.js new file mode 100644 index 0000000000..56ab7f7e1a --- /dev/null +++ b/test/dev/runtime-config.test.js @@ -0,0 +1,45 @@ +import { loadFixture, getPort, Nuxt } from '../utils' + +let port +const url = route => 'http://localhost:' + port + route + +let nuxt = null + +describe('basic ssr', () => { + beforeAll(async () => { + const options = await loadFixture('runtime-config') + nuxt = new Nuxt(options) + await nuxt.ready() + + port = await getPort() + await nuxt.server.listen(port, '0.0.0.0') + }) + + test('SSR payload', async () => { + const window = await nuxt.server.renderAndGetWindow(url('/')) + const payload = window.__NUXT__ + + expect(payload.config).toMatchObject({ + baseURL: '/api' + }) + + expect(payload.data[0].serverConfig).toMatchObject({ + baseURL: 'https://google.com/api', + API_SECRET: '1234' + }) + }) + + test('SPA payload ', async () => { + const window = await nuxt.server.renderAndGetWindow(url('/?spa')) + const payload = window.__NUXT__ + + expect(payload.config).toMatchObject({ + baseURL: '/api' + }) + }) + + // Close server and ask nuxt to stop listening to file changes + afterAll(async () => { + await nuxt.close() + }) +}) diff --git a/test/dev/unicode-base.size-limit.test.js b/test/dev/unicode-base.size-limit.test.js index d00c9918e8..6f22f4c9cf 100644 --- a/test/dev/unicode-base.size-limit.test.js +++ b/test/dev/unicode-base.size-limit.test.js @@ -20,7 +20,7 @@ describe('nuxt minimal vue-app bundle size limit', () => { it('should stay within the size limit range', async () => { const filter = filename => filename === 'vue-app.nuxt.js' const legacyResourcesSize = await getResourcesSize(distDir, 'client', { filter }) - const LEGACY_JS_RESOURCES_KB_SIZE = 16.2 + const LEGACY_JS_RESOURCES_KB_SIZE = 16.5 expect(legacyResourcesSize.uncompressed).toBeWithinSize(LEGACY_JS_RESOURCES_KB_SIZE) }) }) diff --git a/test/fixtures/runtime-config/.env b/test/fixtures/runtime-config/.env new file mode 100644 index 0000000000..138fca756f --- /dev/null +++ b/test/fixtures/runtime-config/.env @@ -0,0 +1,4 @@ +BASE_URL=/api +PUBLIC_URL=https://google.com +SERVER_BASE_URL +API_SECRET=1234 diff --git a/test/fixtures/runtime-config/nuxt.config.js b/test/fixtures/runtime-config/nuxt.config.js new file mode 100644 index 0000000000..52b757e3f5 --- /dev/null +++ b/test/fixtures/runtime-config/nuxt.config.js @@ -0,0 +1,17 @@ +export default { + publicRuntimeConfig: { + baseURL: process.env.BASE_URL + }, + privateRuntimeConfig: { + baseURL: '${PUBLIC_URL}${BASE_URL}', + API_SECRET: '' + }, + serverMiddleware: [ + (req, _, next) => { + if (req.url.includes('?spa')) { + req.spa = true + } + next() + } + ] +} diff --git a/test/fixtures/runtime-config/pages/index.vue b/test/fixtures/runtime-config/pages/index.vue new file mode 100644 index 0000000000..d7320eefe2 --- /dev/null +++ b/test/fixtures/runtime-config/pages/index.vue @@ -0,0 +1,30 @@ + + + diff --git a/test/fixtures/runtime-config/runtime-config.test.js b/test/fixtures/runtime-config/runtime-config.test.js new file mode 100644 index 0000000000..162761e6c4 --- /dev/null +++ b/test/fixtures/runtime-config/runtime-config.test.js @@ -0,0 +1,3 @@ +import { buildFixture } from '../../utils/build' + +buildFixture('runtime-config') diff --git a/test/utils/nuxt.js b/test/utils/nuxt.js index 81d02c6044..a5c866cb3e 100644 --- a/test/utils/nuxt.js +++ b/test/utils/nuxt.js @@ -2,6 +2,7 @@ import path from 'path' import { defaultsDeep } from 'lodash' import { version as coreVersion } from '../../packages/core/package.json' +import { loadNuxtConfig } from '../../packages/config/src/index' export { Nuxt } from '../../packages/core/src/index' export { Builder } from '../../packages/builder/src/index' @@ -13,25 +14,13 @@ export const version = `v${coreVersion}` export const loadFixture = async function (fixture, overrides) { const rootDir = path.resolve(__dirname, '..', 'fixtures', fixture) - let config = {} - - try { - config = await import(`../fixtures/${fixture}/nuxt.config`) - config = config.default || config - } catch (e) { - // Ignore MODULE_NOT_FOUND - if (e.code !== 'MODULE_NOT_FOUND') { - throw e + const config = await loadNuxtConfig({ + rootDir, + configOverrides: { + dev: false, + test: true } - } - - if (typeof config === 'function') { - config = await config() - } - - config.rootDir = rootDir - config.dev = false - config.test = true + }) // disable terser to speed-up fixture builds if (config.build) { diff --git a/yarn.lock b/yarn.lock index 50a7892fa1..32758fefe6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -252,16 +252,16 @@ chalk "^2.0.0" js-tokens "^4.0.0" +"@babel/parser@7.7.5", "@babel/parser@^7.7.0": + version "7.7.5" + resolved "https://registry.npmjs.org/@babel/parser/-/parser-7.7.5.tgz#cbf45321619ac12d83363fcf9c94bb67fa646d71" + integrity sha512-KNlOe9+/nk4i29g0VXgl8PEXIRms5xKLJeuZ6UptN0fHv+jDiriG+y94X6qAgWTR0h3KaoM1wK5G5h7MHFRSig== + "@babel/parser@^7.1.0", "@babel/parser@^7.8.6", "@babel/parser@^7.9.6": version "7.9.6" resolved "https://registry.npmjs.org/@babel/parser/-/parser-7.9.6.tgz#3b1bbb30dabe600cd72db58720998376ff653bc7" integrity sha512-AoeIEJn8vt+d/6+PXDRPaksYhnlbMIiejioBZvvMQsOjW/JYK6k/0dKnvvP3EhK5GfMBWDPtrxRtegWdAcdq9Q== -"@babel/parser@^7.7.0": - version "7.7.5" - resolved "https://registry.npmjs.org/@babel/parser/-/parser-7.7.5.tgz#cbf45321619ac12d83363fcf9c94bb67fa646d71" - integrity sha512-KNlOe9+/nk4i29g0VXgl8PEXIRms5xKLJeuZ6UptN0fHv+jDiriG+y94X6qAgWTR0h3KaoM1wK5G5h7MHFRSig== - "@babel/plugin-proposal-async-generator-functions@^7.8.3": version "7.8.3" resolved "https://registry.npmjs.org/@babel/plugin-proposal-async-generator-functions/-/plugin-proposal-async-generator-functions-7.8.3.tgz#bad329c670b382589721b27540c7d288601c6e6f" @@ -4951,6 +4951,11 @@ dot-prop@^5.2.0: dependencies: is-obj "^2.0.0" +dotenv@^8.2.0: + version "8.2.0" + resolved "https://registry.npmjs.org/dotenv/-/dotenv-8.2.0.tgz#97e619259ada750eea3e4ea3e26bceea5424b16a" + integrity sha512-8sJ78ElpbDJBHNeBzUbUVLsqKdccaa/BXF1uPTw3GrvQTBgrQrtObr2mUrE38vzYd8cEv+m/JBfDLioYcfXoaw== + duplexer@^0.1.1: version "0.1.1" resolved "https://registry.npmjs.org/duplexer/-/duplexer-0.1.1.tgz#ace6ff808c1ce66b57d1ebf97977acb02334cfc1"