feat(nuxt): app.config with hmr and reactivity support (#6333)

Co-authored-by: Daniel Roe <daniel@roe.dev>
This commit is contained in:
pooya parsa 2022-08-17 17:23:13 +02:00 committed by GitHub
parent 405629dc35
commit 94f76ea930
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 324 additions and 26 deletions

View File

@ -0,0 +1,55 @@
# App Config
::StabilityEdge
::
Nuxt 3 provides an `app.config` config file to expose reactive configuration within your application with the ability to update it at runtime within lifecycle or using a nuxt plugin and editing it with HMR (hot-module-replacement).
::alert{type=warning}
Do not put any secret values inside `app.config` file. It is exposed to the user client bundle.
::
## Defining App Config
To expose config and environment variables to the rest of your app, you will need to define configuration in `app.config` file.
**Example:**
```ts [app.config.ts]
export default defineAppConfig({
theme: {
primaryColor: '#ababab'
}
})
```
When adding `theme` to the `app.config`, Nuxt uses Vite or Webpack to bundle the code. We can universally access `theme` in both server and browser using [useAppConfig](/api/composables/use-app-config) composable.
```js
const appConfig = useAppConfig()
console.log(appConfig.theme)
```
<!-- TODO: Document module author for extension -->
### Manually Typing App Config
Nuxt tries to automatically generate a typescript interface from provided app config.
It is also possible to type app config manually:
```ts [index.d.ts]
declare module '@nuxt/schema' {
interface AppConfig {
/** Theme configuration */
theme: {
/** Primary app color */
primaryColor: string
}
}
}
// It is always important to ensure you import/export something when augmenting a type
export {}
```

View File

@ -0,0 +1,24 @@
---
icon: IconFile
title: app.config.ts
head.title: Nuxt App Config
---
# Nuxt App Config
::StabilityEdge
::
You can easily provide runtime app configuration using `app.config.ts` file. It can have either of `.ts`, `.js`, or `.mjs` extensions.
```ts [app.config.ts]
export default defineAppConfig({
foo: 'bar'
})
```
::alert{type=warning}
Do not put any secret values inside `app.config` file. It is exposed to the user client bundle.
::
::ReadMore{link="/guide/features/app-config"}

View File

@ -0,0 +1,16 @@
# `useAppConfig`
::StabilityEdge
::
Access [app config](/guide/features/app-config):
**Usage:**
```js
const appConfig = useAppConfig()
console.log(appConfig)
```
::ReadMore{link="/guide/features/app-config"}

View File

@ -0,0 +1,5 @@
export default defineAppConfig({
foo: 'user',
bar: 'user',
baz: 'base'
})

View File

@ -0,0 +1,4 @@
export default defineAppConfig({
bar: 'base',
baz: 'base'
})

View File

@ -1,12 +1,15 @@
<script setup> <script setup>
const themeConfig = useRuntimeConfig().theme const themeConfig = useRuntimeConfig().theme
const appConfig = useAppConfig()
const foo = useFoo() const foo = useFoo()
const bar = getBar() const bar = getBar()
</script> </script>
<template> <template>
<NuxtExampleLayout example="advanced/config-extends"> <NuxtExampleLayout example="advanced/config-extends">
theme runtimeConfig appConfig:
<pre>{{ JSON.stringify(appConfig, null, 2) }}</pre>
runtimeConfig:
<pre>{{ JSON.stringify(themeConfig, null, 2) }}</pre> <pre>{{ JSON.stringify(themeConfig, null, 2) }}</pre>
<BaseButton>Base Button</BaseButton> <BaseButton>Base Button</BaseButton>
<FancyButton>Fancy Button</FancyButton> <FancyButton>Fancy Button</FancyButton>

View File

@ -132,7 +132,7 @@ export default defineNuxtCommand({
dLoad(true, `Directory \`${relativePath}/\` ${event === 'addDir' ? 'created' : 'removed'}`) dLoad(true, `Directory \`${relativePath}/\` ${event === 'addDir' ? 'created' : 'removed'}`)
} }
} else if (isFileChange) { } else if (isFileChange) {
if (file.match(/(app|error)\.(js|ts|mjs|jsx|tsx|vue)$/)) { if (file.match(/(app|error|app\.config)\.(js|ts|mjs|jsx|tsx|vue)$/)) {
dLoad(true, `\`${relativePath}\` ${event === 'add' ? 'created' : 'removed'}`) dLoad(true, `\`${relativePath}\` ${event === 'add' ? 'created' : 'removed'}`)
} }
} }

View File

@ -0,0 +1,48 @@
import type { AppConfig } from '@nuxt/schema'
import { reactive } from 'vue'
import { useNuxtApp } from './nuxt'
// @ts-ignore
import __appConfig from '#build/app.config.mjs'
// Workaround for vite HMR with virtual modules
export const _getAppConfig = () => __appConfig as AppConfig
export function useAppConfig (): AppConfig {
const nuxtApp = useNuxtApp()
if (!nuxtApp._appConfig) {
nuxtApp._appConfig = reactive(__appConfig) as AppConfig
}
return nuxtApp._appConfig
}
// HMR Support
if (process.dev) {
function applyHMR (newConfig: AppConfig) {
const appConfig = useAppConfig()
if (newConfig && appConfig) {
for (const key in newConfig) {
(appConfig as any)[key] = (newConfig as any)[key]
}
for (const key in appConfig) {
if (!(key in newConfig)) {
delete (appConfig as any)[key]
}
}
}
}
// Vite
if (import.meta.hot) {
import.meta.hot.accept((newModule) => {
const newConfig = newModule._getAppConfig()
applyHMR(newConfig)
})
}
// Webpack
if (import.meta.webpackHot) {
import.meta.webpackHot.accept('#build/app.config.mjs', () => {
applyHMR(__appConfig)
})
}
}

View File

@ -3,6 +3,7 @@
export * from './nuxt' export * from './nuxt'
export * from './composables' export * from './composables'
export * from './components' export * from './components'
export * from './config'
// eslint-disable-next-line import/no-restricted-paths // eslint-disable-next-line import/no-restricted-paths
export type { PageMeta } from '../pages/runtime' export type { PageMeta } from '../pages/runtime'

View File

@ -2,7 +2,7 @@
import { getCurrentInstance, reactive } from 'vue' import { getCurrentInstance, reactive } from 'vue'
import type { App, onErrorCaptured, VNode } from 'vue' import type { App, onErrorCaptured, VNode } from 'vue'
import { createHooks, Hookable } from 'hookable' import { createHooks, Hookable } from 'hookable'
import type { RuntimeConfig } from '@nuxt/schema' import type { RuntimeConfig, AppConfigInput } from '@nuxt/schema'
import { getContext } from 'unctx' import { getContext } from 'unctx'
import type { SSRContext } from 'vue-bundle-renderer/runtime' import type { SSRContext } from 'vue-bundle-renderer/runtime'
import type { CompatibilityEvent } from 'h3' import type { CompatibilityEvent } from 'h3'
@ -281,3 +281,7 @@ export function useRuntimeConfig (): RuntimeConfig {
function defineGetter<K extends string | number | symbol, V> (obj: Record<K, V>, key: K, val: V) { function defineGetter<K extends string | number | symbol, V> (obj: Record<K, V>, key: K, val: V) {
Object.defineProperty(obj, key, { get: () => val }) Object.defineProperty(obj, key, { get: () => val })
} }
export function defineAppConfig<C extends AppConfigInput> (config: C): C {
return config
}

View File

@ -1,6 +1,6 @@
import { defineUnimportPreset, Preset } from 'unimport' import { defineUnimportPreset, Preset } from 'unimport'
export const commonPresets: Preset[] = [ const commonPresets: Preset[] = [
// #head // #head
defineUnimportPreset({ defineUnimportPreset({
from: '#head', from: '#head',
@ -19,7 +19,7 @@ export const commonPresets: Preset[] = [
}) })
] ]
export const appPreset = defineUnimportPreset({ const appPreset = defineUnimportPreset({
from: '#app', from: '#app',
imports: [ imports: [
'useAsyncData', 'useAsyncData',
@ -49,12 +49,14 @@ export const appPreset = defineUnimportPreset({
'isNuxtError', 'isNuxtError',
'useError', 'useError',
'createError', 'createError',
'defineNuxtLink' 'defineNuxtLink',
'useAppConfig',
'defineAppConfig'
] ]
}) })
// vue // vue
export const vuePreset = defineUnimportPreset({ const vuePreset = defineUnimportPreset({
from: 'vue', from: 'vue',
imports: [ imports: [
// <script setup> // <script setup>

View File

@ -115,6 +115,15 @@ export async function resolveApp (nuxt: Nuxt, app: NuxtApp) {
app.middleware = uniqueBy(await resolvePaths(app.middleware, 'path'), 'name') app.middleware = uniqueBy(await resolvePaths(app.middleware, 'path'), 'name')
app.plugins = uniqueBy(await resolvePaths(app.plugins, 'src'), 'src') app.plugins = uniqueBy(await resolvePaths(app.plugins, 'src'), 'src')
// Resolve app.config
app.configs = []
for (const config of nuxt.options._layers.map(layer => layer.config)) {
const appConfigPath = await findPath(resolve(config.srcDir, 'app.config'))
if (appConfigPath) {
app.configs.push(appConfigPath)
}
}
// Extend app // Extend app
await nuxt.callHook('app:resolve', app) await nuxt.callHook('app:resolve', app)

View File

@ -54,6 +54,7 @@ async function initNuxt (nuxt: Nuxt) {
} }
// Add module augmentations directly to NuxtConfig // Add module augmentations directly to NuxtConfig
opts.references.push({ path: resolve(nuxt.options.buildDir, 'types/schema.d.ts') }) opts.references.push({ path: resolve(nuxt.options.buildDir, 'types/schema.d.ts') })
opts.references.push({ path: resolve(nuxt.options.buildDir, 'types/app.config.d.ts') })
}) })
// Add import protection // Add import protection

View File

@ -124,7 +124,7 @@ export const schemaTemplate: NuxtTemplate<TemplateContext> = {
"declare module '@nuxt/schema' {", "declare module '@nuxt/schema' {",
' interface NuxtConfig {', ' interface NuxtConfig {',
...moduleInfo.filter(Boolean).map(meta => ...moduleInfo.filter(Boolean).map(meta =>
` [${genString(meta.configKey)}]?: typeof ${genDynamicImport(meta.importName, { wrapper: false })}.default extends NuxtModule<infer O> ? Partial<O> : Record<string, any>` ` [${genString(meta.configKey)}]?: typeof ${genDynamicImport(meta.importName, { wrapper: false })}.default extends NuxtModule<infer O> ? Partial<O> : Record<string, any>`
), ),
' }', ' }',
generateTypes(resolveSchema(Object.fromEntries(Object.entries(nuxt.options.runtimeConfig).filter(([key]) => key !== 'public'))), generateTypes(resolveSchema(Object.fromEntries(Object.entries(nuxt.options.runtimeConfig).filter(([key]) => key !== 'public'))),
@ -157,7 +157,7 @@ export const layoutTemplate: NuxtTemplate<TemplateContext> = {
})) }))
return [ return [
'import { defineAsyncComponent } from \'vue\'', 'import { defineAsyncComponent } from \'vue\'',
`export default ${layoutsObject}` `export default ${layoutsObject}`
].join('\n') ].join('\n')
} }
} }
@ -184,7 +184,39 @@ export const useRuntimeConfig = () => window?.__NUXT__?.config || {}
` `
} }
export const publicPathTemplate: NuxtTemplate<TemplateContext> = { export const appConfigDeclarationTemplate: NuxtTemplate = {
filename: 'types/app.config.d.ts',
getContents: ({ app, nuxt }) => {
return `
import type { Defu } from 'defu'
${app.configs.map((id: string, index: number) => `import ${`cfg${index}`} from ${JSON.stringify(id.replace(/(?<=\w)\.\w+$/g, ''))}`).join('\n')}
declare const inlineConfig = ${JSON.stringify(nuxt.options.appConfig, null, 2)}
type ResolvedAppConfig = Defu<typeof inlineConfig, [${app.configs.map((_id: string, index: number) => `typeof cfg${index}`).join(', ')}]>
declare module '@nuxt/schema' {
interface AppConfig extends ResolvedAppConfig { }
}
`
}
}
export const appConfigTemplate: NuxtTemplate = {
filename: 'app.config.mjs',
write: true,
getContents: ({ app, nuxt }) => {
return `
import defu from 'defu'
const inlineConfig = ${JSON.stringify(nuxt.options.appConfig, null, 2)}
${app.configs.map((id: string, index: number) => `import ${`cfg${index}`} from ${JSON.stringify(id)}`).join('\n')}
export default defu(${app.configs.map((_id: string, index: number) => `cfg${index}`).concat(['inlineConfig']).join(', ')})
`
}
}
export const publicPathTemplate: NuxtTemplate = {
filename: 'paths.mjs', filename: 'paths.mjs',
getContents ({ nuxt }) { getContents ({ nuxt }) {
return [ return [

View File

@ -747,5 +747,16 @@ export default {
* @version 3 * @version 3
* @deprecated Use `runtimeConfig` option with `public` key (`runtimeConfig.public.*`). * @deprecated Use `runtimeConfig` option with `public` key (`runtimeConfig.public.*`).
*/ */
publicRuntimeConfig: {} publicRuntimeConfig: {},
/**
* Additional app configuration
*
* For programmatic usage and type support, you can directly provide app config with this option.
* It will be merged with `app.config` file as default value.
*
* @type {typeof import('../src/types/config').AppConfig}
* @version 3
*/
appConfig: {},
} }

View File

@ -27,6 +27,28 @@ export interface NuxtOptions extends ConfigSchema {
_layers: NuxtConfigLayer[] _layers: NuxtConfigLayer[]
} }
export interface ViteConfig extends ViteUserConfig {
/**
* Options passed to @vitejs/plugin-vue
* @see https://github.com/vitejs/vite/tree/main/packages/plugin-vue
*/
vue?: VuePluginOptions
/**
* Bundler for dev time server-side rendering.
* @default 'vite-node'
*/
devBundler?: 'vite-node' | 'legacy',
/**
* Warmup vite entrypoint caches on dev startup.
*/
warmupEntry?: boolean
}
// -- Runtime Config --
type RuntimeConfigNamespace = Record<string, any> type RuntimeConfigNamespace = Record<string, any>
export interface PublicRuntimeConfig extends RuntimeConfigNamespace { } export interface PublicRuntimeConfig extends RuntimeConfigNamespace { }
@ -39,19 +61,14 @@ export interface RuntimeConfig extends PrivateRuntimeConfig, RuntimeConfigNamesp
public: PublicRuntimeConfig public: PublicRuntimeConfig
} }
export interface ViteConfig extends ViteUserConfig { // -- App Config --
/** export interface AppConfigInput extends Record<string, any> {
* Options passed to @vitejs/plugin-vue /** @deprecated reserved */
* @see https://github.com/vitejs/vite/tree/main/packages/plugin-vue private?: never
*/ /** @deprecated reserved */
vue?: VuePluginOptions nuxt?: never
/** /** @deprecated reserved */
* Bundler for dev time server-side rendering. nitro?: never
* @default 'vite-node'
*/
devBundler?: 'vite-node' | 'legacy',
/**
* Warmup vite entrypoint caches on dev startup.
*/
warmupEntry?: boolean
} }
export interface AppConfig { }

View File

@ -60,6 +60,7 @@ export interface NuxtApp {
layouts: Record<string, NuxtLayout> layouts: Record<string, NuxtLayout>
middleware: NuxtMiddleware[] middleware: NuxtMiddleware[]
templates: NuxtTemplate[] templates: NuxtTemplate[]
configs: string[]
} }
type _TemplatePlugin = Omit<NuxtPlugin, 'src'> & NuxtTemplate type _TemplatePlugin = Omit<NuxtPlugin, 'src'> & NuxtTemplate

View File

@ -449,4 +449,28 @@ describe('dynamic paths', () => {
).toBeTruthy() ).toBeTruthy()
} }
}) })
it('restore server', async () => {
process.env.NUXT_APP_BASE_URL = undefined
process.env.NUXT_APP_CDN_URL = undefined
process.env.NUXT_APP_BUILD_ASSETS_DIR = undefined
await startServer()
})
})
describe('app config', () => {
it('should work', async () => {
const html = await $fetch('/app-config')
const expectedAppConfig = {
fromNuxtConfig: true,
nested: {
val: 2
},
fromLayer: true,
userConfig: 123
}
expect(html).toContain(JSON.stringify(expectedAppConfig))
})
}) })

6
test/fixtures/basic/app.config.ts vendored Normal file
View File

@ -0,0 +1,6 @@
export default defineAppConfig({
userConfig: 123,
nested: {
val: 2
}
})

View File

@ -0,0 +1,3 @@
export default {
fromLayer: true
}

View File

@ -37,5 +37,11 @@ export default defineNuxtConfig({
experimental: { experimental: {
reactivityTransform: true, reactivityTransform: true,
treeshakeClientOnly: true treeshakeClientOnly: true
},
appConfig: {
fromNuxtConfig: true,
nested: {
val: 1
}
} }
}) })

View File

@ -0,0 +1,11 @@
<template>
<div>
App Config:
<!-- eslint-disable-next-line vue/no-v-html -->
<pre v-html="JSON.stringify(appConfig)" />
</div>
</template>
<script setup lang="ts">
const appConfig = useAppConfig()
</script>

View File

@ -1,6 +1,7 @@
import { expectTypeOf } from 'expect-type' import { expectTypeOf } from 'expect-type'
import { describe, it } from 'vitest' import { describe, it } from 'vitest'
import type { Ref } from 'vue' import type { Ref } from 'vue'
import type { AppConfig } from '@nuxt/schema'
import { NavigationFailure, RouteLocationNormalizedLoaded, RouteLocationRaw, useRouter as vueUseRouter } from 'vue-router' import { NavigationFailure, RouteLocationNormalizedLoaded, RouteLocationRaw, useRouter as vueUseRouter } from 'vue-router'
import { defineNuxtConfig } from '~~/../../../packages/nuxt/src' import { defineNuxtConfig } from '~~/../../../packages/nuxt/src'
@ -165,3 +166,17 @@ describe('composables', () => {
.toEqualTypeOf(useLazyAsyncData(() => Promise.resolve({ foo: Math.random() }), { transform: data => data.foo })) .toEqualTypeOf(useLazyAsyncData(() => Promise.resolve({ foo: Math.random() }), { transform: data => data.foo }))
}) })
}) })
describe('app config', () => {
it('merges app config as expected', () => {
interface ExpectedMergedAppConfig {
fromLayer: boolean,
fromNuxtConfig: boolean,
nested: {
val: number
},
userConfig: number
}
expectTypeOf<AppConfig>().toMatchTypeOf<ExpectedMergedAppConfig>()
})
})