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: {
'no-console': 'error',
'no-debugger': 'error',
'no-template-curly-in-string': 0,
quotes: ['error', 'single', { avoidEscape: true, allowTemplateLiterals: true }]
},
overrides: [{

View File

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

View File

@ -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'
}
}

View File

@ -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

View File

@ -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

View File

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

View File

@ -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"
},

View File

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

View File

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

View File

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

View File

@ -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,

View File

@ -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) {

View File

@ -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) {

View File

@ -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

View File

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

View File

@ -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}<div id="${this.serverContext.globals.id}">${this.serverContext.resources.loadingHTML}</div>${meta.BODY_SCRIPTS}`
if (renderContext.staticAssetsBase) {
// Full static, add window.__NUXT_STATIC__
APP += `<script>window.__NUXT_STATIC__='${renderContext.staticAssetsBase}';window.${this.serverContext.globals.context}={spa:!0}</script>`
APP += `<script>window.__NUXT_STATIC__='${renderContext.staticAssetsBase}'</script>`
}
APP += `<script>window.${this.serverContext.globals.context}=${devalue({
config: renderContext.runtimeConfig.public,
spa: true
})}</script>`
// Prepare template params
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 () => {
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)
})
})

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 { 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) {

View File

@ -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"