feat: runtime config and built-in dotenv support (#7312)

Co-authored-by: Sébastien Chopin <seb@nuxtjs.com>
Co-authored-by: Alexander Lichter <manniL@gmx.net>
This commit is contained in:
Pooya Parsa 2020-05-18 10:21:15 +02:00 committed by GitHub
parent 0c81c52c41
commit 0337932115
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 287 additions and 38 deletions

View File

@ -16,6 +16,7 @@ module.exports = {
rules: { rules: {
'no-console': 'error', 'no-console': 'error',
'no-debugger': 'error', 'no-debugger': 'error',
'no-template-curly-in-string': 0,
quotes: ['error', 'single', { avoidEscape: true, allowTemplateLiterals: true }] quotes: ['error', 'single', { avoidEscape: true, allowTemplateLiterals: true }]
}, },
overrides: [{ overrides: [{

View File

@ -770,6 +770,11 @@ export default class Builder {
if (this.ignore.ignoreFile) { if (this.ignore.ignoreFile) {
nuxtRestartWatch.push(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 default page displayed, watch for first page creation
if (this._nuxtPages && this._defaultPage) { if (this._nuxtPages && this._defaultPage) {
nuxtRestartWatch.push(path.join(this.options.srcDir, this.options.dir.pages)) nuxtRestartWatch.push(path.join(this.options.srcDir, this.options.dir.pages))

View File

@ -54,5 +54,15 @@ export default {
alias: 'h', alias: 'h',
type: 'boolean', type: 'boolean',
description: 'Display this message' 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'
} }
} }

View File

@ -11,7 +11,11 @@ export async function loadNuxtConfig (argv, configContext) {
const options = await _loadNuxtConfig({ const options = await _loadNuxtConfig({
rootDir, rootDir,
configFile, configFile,
configContext configContext,
envConfig: {
dotenv: argv.dotenv === 'false' ? false : argv.dotenv,
env: argv.processenv ? process.env : {}
}
}) })
// Nuxt Mode // Nuxt Mode

View File

@ -27,6 +27,12 @@ exports[`cli/command builds help text 1`] = `
--version, -v Display the Nuxt --version, -v Display the Nuxt
version version
--help, -h Display this message --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 --port, -p Port number on which
to start the application to start the application
--hostname, -H Hostname on which to --hostname, -H Hostname on which to

View File

@ -21,8 +21,8 @@ describe('cli/command', () => {
const cmd = new Command({ options: allOptions }) const cmd = new Command({ options: allOptions })
const minimistOptions = cmd._getMinimistOptions() const minimistOptions = cmd._getMinimistOptions()
expect(minimistOptions.string.length).toBe(6) expect(minimistOptions.string.length).toBe(7)
expect(minimistOptions.boolean.length).toBe(5) expect(minimistOptions.boolean.length).toBe(6)
expect(minimistOptions.alias.c).toBe('config-file') expect(minimistOptions.alias.c).toBe('config-file')
expect(minimistOptions.default.c).toBe(common['config-file'].default) expect(minimistOptions.default.c).toBe(common['config-file'].default)
}) })
@ -71,7 +71,13 @@ describe('cli/command', () => {
expect(options.server.port).toBe(3001) expect(options.server.port).toBe(3001)
expect(consola.fatal).toHaveBeenCalledWith('Provided hostname argument has no value') // hostname check expect(consola.fatal).toHaveBeenCalledWith('Provided hostname argument has no value') // hostname check
expect(loadConfigSpy).toHaveBeenCalledTimes(1) 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() loadConfigSpy.mockRestore()
}) })

View File

@ -13,6 +13,7 @@
"@nuxt/utils": "2.12.1", "@nuxt/utils": "2.12.1",
"consola": "^2.12.1", "consola": "^2.12.1",
"defu": "^2.0.2", "defu": "^2.0.2",
"dotenv": "^8.2.0",
"esm": "^3.2.25", "esm": "^3.2.25",
"std-env": "^2.2.1" "std-env": "^2.2.1"
}, },

View File

@ -77,5 +77,9 @@ export default () => ({
editor: undefined, editor: undefined,
// Hooks // Hooks
hooks: null hooks: null,
// runtimeConfig
privateRuntimeConfig: {},
publicRuntimeConfig: {}
}) })

View File

@ -1,12 +1,15 @@
import path from 'path' import path from 'path'
import fs from 'fs'
import defu from 'defu' import defu from 'defu'
import consola from 'consola' import consola from 'consola'
import dotenv from 'dotenv'
import { clearRequireCache, scanRequireTree } from '@nuxt/utils' import { clearRequireCache, scanRequireTree } from '@nuxt/utils'
import esm from 'esm' import esm from 'esm'
import { defaultNuxtConfigFile } from './config' import { defaultNuxtConfigFile } from './config'
export async function loadNuxtConfig ({ export async function loadNuxtConfig ({
rootDir = '.', rootDir = '.',
envConfig = {},
configFile = defaultNuxtConfigFile, configFile = defaultNuxtConfigFile,
configContext = {}, configContext = {},
configOverrides = {} configOverrides = {}
@ -27,6 +30,23 @@ export async function loadNuxtConfig ({
configFile = undefined 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) { if (configFile) {
// Clear cache // Clear cache
clearRequireCache(configFile) clearRequireCache(configFile)
@ -66,5 +86,85 @@ export async function loadNuxtConfig ({
options.rootDir = rootDir 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 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))
}
}

View File

@ -294,6 +294,8 @@ Object {
"name": "page", "name": "page",
}, },
"plugins": Array [], "plugins": Array [],
"privateRuntimeConfig": Object {},
"publicRuntimeConfig": Object {},
"render": Object { "render": Object {
"bundleRenderer": Object { "bundleRenderer": Object {
"runInNewContext": false, "runInNewContext": false,

View File

@ -266,6 +266,8 @@ Object {
"name": "page", "name": "page",
}, },
"plugins": Array [], "plugins": Array [],
"privateRuntimeConfig": Object {},
"publicRuntimeConfig": Object {},
"render": Object { "render": Object {
"bundleRenderer": Object { "bundleRenderer": Object {
"runInNewContext": undefined, "runInNewContext": undefined,
@ -632,6 +634,8 @@ Object {
"name": "page", "name": "page",
}, },
"plugins": Array [], "plugins": Array [],
"privateRuntimeConfig": Object {},
"publicRuntimeConfig": Object {},
"render": Object { "render": Object {
"bundleRenderer": Object { "bundleRenderer": Object {
"runInNewContext": undefined, "runInNewContext": undefined,

View File

@ -98,7 +98,7 @@ Vue.config.$nuxt.<%= globals.nuxt %> = true
const errorHandler = Vue.config.errorHandler || console.error const errorHandler = Vue.config.errorHandler || console.error
// Create and mount App // Create and mount App
createApp().then(mountApp).catch(errorHandler) createApp(null, NUXT.config).then(mountApp).catch(errorHandler)
<% if (features.transitions) { %> <% if (features.transitions) { %>
function componentOption (component, key, ...args) { function componentOption (component, key, ...args) {

View File

@ -66,7 +66,7 @@ const defaultTransition = <%=
%><%= isTest ? '// eslint-disable-line' : '' %> %><%= isTest ? '// eslint-disable-line' : '' %>
<% } %> <% } %>
async function createApp (ssrContext) { async function createApp(ssrContext, config = {}) {
const router = await createRouter(ssrContext) const router = await createRouter(ssrContext)
<% if (store) { %> <% if (store) { %>
@ -162,8 +162,7 @@ async function createApp (ssrContext) {
ssrContext ssrContext
}) })
<% if (plugins.length) { %> function inject(key, value) {
const inject = function (key, value) {
if (!key) { if (!key) {
throw new Error('inject(key, value) has no key provided') 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 (store) { %>
if (process.client) { if (process.client) {

View File

@ -77,9 +77,10 @@ export default async (ssrContext) => {
if (process.static && ssrContext.url) { if (process.static && ssrContext.url) {
ssrContext.url = ssrContext.url.split('?')[0] 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) // 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) const _app = new Vue(app)
// Add ssr route path to nuxt context so we can account for page navigation between ssr and csr // 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 ssrContext.nuxt.routePath = app.context.route.path

View File

@ -291,6 +291,12 @@ export default class VueRenderer {
renderContext.modern = modernMode === 'client' || isModernRequest(req, modernMode) 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 // Call renderContext hook
await this.serverContext.nuxt.callHook('vue-renderer:context', renderContext) await this.serverContext.nuxt.callHook('vue-renderer:context', renderContext)

View File

@ -3,6 +3,7 @@ import cloneDeep from 'lodash/cloneDeep'
import VueMeta from 'vue-meta' import VueMeta from 'vue-meta'
import { createRenderer } from 'vue-server-renderer' import { createRenderer } from 'vue-server-renderer'
import LRU from 'lru-cache' import LRU from 'lru-cache'
import devalue from '@nuxt/devalue'
import { TARGETS, isModernRequest } from '@nuxt/utils' import { TARGETS, isModernRequest } from '@nuxt/utils'
import BaseRenderer from './base' import BaseRenderer from './base'
@ -148,12 +149,16 @@ export default class SPARenderer extends BaseRenderer {
} }
} }
// Serialize state (runtime config)
let APP = `${meta.BODY_SCRIPTS_PREPEND}<div id="${this.serverContext.globals.id}">${this.serverContext.resources.loadingHTML}</div>${meta.BODY_SCRIPTS}` let APP = `${meta.BODY_SCRIPTS_PREPEND}<div id="${this.serverContext.globals.id}">${this.serverContext.resources.loadingHTML}</div>${meta.BODY_SCRIPTS}`
if (renderContext.staticAssetsBase) { if (renderContext.staticAssetsBase) {
// Full static, add window.__NUXT_STATIC__ APP += `<script>window.__NUXT_STATIC__='${renderContext.staticAssetsBase}'</script>`
APP += `<script>window.__NUXT_STATIC__='${renderContext.staticAssetsBase}';window.${this.serverContext.globals.context}={spa:!0}</script>`
} }
APP += `<script>window.${this.serverContext.globals.context}=${devalue({
config: renderContext.runtimeConfig.public,
spa: true
})}</script>`
// Prepare template params // Prepare template params
const templateParams = { const templateParams = {

View File

@ -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()
})
})

View File

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

4
test/fixtures/runtime-config/.env vendored Normal file
View File

@ -0,0 +1,4 @@
BASE_URL=/api
PUBLIC_URL=https://google.com
SERVER_BASE_URL
API_SECRET=1234

View File

@ -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()
}
]
}

View File

@ -0,0 +1,30 @@
<template>
<pre v-text="JSON.stringify(config, null, 2)" />
</template>
<script>
export default {
asyncData ({ $config }) {
return {
serverConfig: $config
}
},
data () {
return {
clientConfig: { please: 'wait' }
}
},
computed: {
config () {
return {
client: this.clientConfig,
server: this.serverConfig,
$config: this.$config
}
}
},
mounted () {
this.clientConfig = this.$config
}
}
</script>

View File

@ -0,0 +1,3 @@
import { buildFixture } from '../../utils/build'
buildFixture('runtime-config')

View File

@ -2,6 +2,7 @@
import path from 'path' import path from 'path'
import { defaultsDeep } from 'lodash' import { defaultsDeep } from 'lodash'
import { version as coreVersion } from '../../packages/core/package.json' import { version as coreVersion } from '../../packages/core/package.json'
import { loadNuxtConfig } from '../../packages/config/src/index'
export { Nuxt } from '../../packages/core/src/index' export { Nuxt } from '../../packages/core/src/index'
export { Builder } from '../../packages/builder/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) { export const loadFixture = async function (fixture, overrides) {
const rootDir = path.resolve(__dirname, '..', 'fixtures', fixture) const rootDir = path.resolve(__dirname, '..', 'fixtures', fixture)
let config = {} const config = await loadNuxtConfig({
rootDir,
try { configOverrides: {
config = await import(`../fixtures/${fixture}/nuxt.config`) dev: false,
config = config.default || config test: true
} catch (e) {
// Ignore MODULE_NOT_FOUND
if (e.code !== 'MODULE_NOT_FOUND') {
throw e
} }
} })
if (typeof config === 'function') {
config = await config()
}
config.rootDir = rootDir
config.dev = false
config.test = true
// disable terser to speed-up fixture builds // disable terser to speed-up fixture builds
if (config.build) { if (config.build) {

View File

@ -252,16 +252,16 @@
chalk "^2.0.0" chalk "^2.0.0"
js-tokens "^4.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": "@babel/parser@^7.1.0", "@babel/parser@^7.8.6", "@babel/parser@^7.9.6":
version "7.9.6" version "7.9.6"
resolved "https://registry.npmjs.org/@babel/parser/-/parser-7.9.6.tgz#3b1bbb30dabe600cd72db58720998376ff653bc7" resolved "https://registry.npmjs.org/@babel/parser/-/parser-7.9.6.tgz#3b1bbb30dabe600cd72db58720998376ff653bc7"
integrity sha512-AoeIEJn8vt+d/6+PXDRPaksYhnlbMIiejioBZvvMQsOjW/JYK6k/0dKnvvP3EhK5GfMBWDPtrxRtegWdAcdq9Q== 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": "@babel/plugin-proposal-async-generator-functions@^7.8.3":
version "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" 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: dependencies:
is-obj "^2.0.0" 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: duplexer@^0.1.1:
version "0.1.1" version "0.1.1"
resolved "https://registry.npmjs.org/duplexer/-/duplexer-0.1.1.tgz#ace6ff808c1ce66b57d1ebf97977acb02334cfc1" resolved "https://registry.npmjs.org/duplexer/-/duplexer-0.1.1.tgz#ace6ff808c1ce66b57d1ebf97977acb02334cfc1"