mirror of
https://github.com/nuxt/nuxt.git
synced 2024-11-22 05:35:13 +00:00
feat(nuxt): app.config
with hmr and reactivity support (#6333)
Co-authored-by: Daniel Roe <daniel@roe.dev>
This commit is contained in:
parent
405629dc35
commit
94f76ea930
55
docs/content/2.guide/2.features/10.app-config.md
Normal file
55
docs/content/2.guide/2.features/10.app-config.md
Normal 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 {}
|
||||
```
|
24
docs/content/2.guide/3.directory-structure/16.app.config.md
Normal file
24
docs/content/2.guide/3.directory-structure/16.app.config.md
Normal 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"}
|
16
docs/content/3.api/1.composables/use-app-config.md
Normal file
16
docs/content/3.api/1.composables/use-app-config.md
Normal 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"}
|
5
examples/advanced/config-extends/app.config.ts
Normal file
5
examples/advanced/config-extends/app.config.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export default defineAppConfig({
|
||||
foo: 'user',
|
||||
bar: 'user',
|
||||
baz: 'base'
|
||||
})
|
4
examples/advanced/config-extends/base/app.config.ts
Normal file
4
examples/advanced/config-extends/base/app.config.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export default defineAppConfig({
|
||||
bar: 'base',
|
||||
baz: 'base'
|
||||
})
|
@ -1,12 +1,15 @@
|
||||
<script setup>
|
||||
const themeConfig = useRuntimeConfig().theme
|
||||
const appConfig = useAppConfig()
|
||||
const foo = useFoo()
|
||||
const bar = getBar()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NuxtExampleLayout example="advanced/config-extends">
|
||||
theme runtimeConfig
|
||||
appConfig:
|
||||
<pre>{{ JSON.stringify(appConfig, null, 2) }}</pre>
|
||||
runtimeConfig:
|
||||
<pre>{{ JSON.stringify(themeConfig, null, 2) }}</pre>
|
||||
<BaseButton>Base Button</BaseButton>
|
||||
<FancyButton>Fancy Button</FancyButton>
|
||||
|
@ -132,7 +132,7 @@ export default defineNuxtCommand({
|
||||
dLoad(true, `Directory \`${relativePath}/\` ${event === 'addDir' ? 'created' : 'removed'}`)
|
||||
}
|
||||
} 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'}`)
|
||||
}
|
||||
}
|
||||
|
48
packages/nuxt/src/app/config.ts
Normal file
48
packages/nuxt/src/app/config.ts
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
@ -3,6 +3,7 @@
|
||||
export * from './nuxt'
|
||||
export * from './composables'
|
||||
export * from './components'
|
||||
export * from './config'
|
||||
|
||||
// eslint-disable-next-line import/no-restricted-paths
|
||||
export type { PageMeta } from '../pages/runtime'
|
||||
|
@ -2,7 +2,7 @@
|
||||
import { getCurrentInstance, reactive } from 'vue'
|
||||
import type { App, onErrorCaptured, VNode } from 'vue'
|
||||
import { createHooks, Hookable } from 'hookable'
|
||||
import type { RuntimeConfig } from '@nuxt/schema'
|
||||
import type { RuntimeConfig, AppConfigInput } from '@nuxt/schema'
|
||||
import { getContext } from 'unctx'
|
||||
import type { SSRContext } from 'vue-bundle-renderer/runtime'
|
||||
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) {
|
||||
Object.defineProperty(obj, key, { get: () => val })
|
||||
}
|
||||
|
||||
export function defineAppConfig<C extends AppConfigInput> (config: C): C {
|
||||
return config
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { defineUnimportPreset, Preset } from 'unimport'
|
||||
|
||||
export const commonPresets: Preset[] = [
|
||||
const commonPresets: Preset[] = [
|
||||
// #head
|
||||
defineUnimportPreset({
|
||||
from: '#head',
|
||||
@ -19,7 +19,7 @@ export const commonPresets: Preset[] = [
|
||||
})
|
||||
]
|
||||
|
||||
export const appPreset = defineUnimportPreset({
|
||||
const appPreset = defineUnimportPreset({
|
||||
from: '#app',
|
||||
imports: [
|
||||
'useAsyncData',
|
||||
@ -49,12 +49,14 @@ export const appPreset = defineUnimportPreset({
|
||||
'isNuxtError',
|
||||
'useError',
|
||||
'createError',
|
||||
'defineNuxtLink'
|
||||
'defineNuxtLink',
|
||||
'useAppConfig',
|
||||
'defineAppConfig'
|
||||
]
|
||||
})
|
||||
|
||||
// vue
|
||||
export const vuePreset = defineUnimportPreset({
|
||||
const vuePreset = defineUnimportPreset({
|
||||
from: 'vue',
|
||||
imports: [
|
||||
// <script setup>
|
||||
|
@ -115,6 +115,15 @@ export async function resolveApp (nuxt: Nuxt, app: NuxtApp) {
|
||||
app.middleware = uniqueBy(await resolvePaths(app.middleware, 'path'), 'name')
|
||||
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
|
||||
await nuxt.callHook('app:resolve', app)
|
||||
|
||||
|
@ -54,6 +54,7 @@ async function initNuxt (nuxt: Nuxt) {
|
||||
}
|
||||
// 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/app.config.d.ts') })
|
||||
})
|
||||
|
||||
// Add import protection
|
||||
|
@ -124,7 +124,7 @@ export const schemaTemplate: NuxtTemplate<TemplateContext> = {
|
||||
"declare module '@nuxt/schema' {",
|
||||
' interface NuxtConfig {',
|
||||
...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'))),
|
||||
@ -157,7 +157,7 @@ export const layoutTemplate: NuxtTemplate<TemplateContext> = {
|
||||
}))
|
||||
return [
|
||||
'import { defineAsyncComponent } from \'vue\'',
|
||||
`export default ${layoutsObject}`
|
||||
`export default ${layoutsObject}`
|
||||
].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',
|
||||
getContents ({ nuxt }) {
|
||||
return [
|
||||
|
@ -747,5 +747,16 @@ export default {
|
||||
* @version 3
|
||||
* @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: {},
|
||||
}
|
||||
|
@ -27,6 +27,28 @@ export interface NuxtOptions extends ConfigSchema {
|
||||
_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>
|
||||
|
||||
export interface PublicRuntimeConfig extends RuntimeConfigNamespace { }
|
||||
@ -39,19 +61,14 @@ export interface RuntimeConfig extends PrivateRuntimeConfig, RuntimeConfigNamesp
|
||||
public: PublicRuntimeConfig
|
||||
}
|
||||
|
||||
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
|
||||
// -- App Config --
|
||||
export interface AppConfigInput extends Record<string, any> {
|
||||
/** @deprecated reserved */
|
||||
private?: never
|
||||
/** @deprecated reserved */
|
||||
nuxt?: never
|
||||
/** @deprecated reserved */
|
||||
nitro?: never
|
||||
}
|
||||
|
||||
export interface AppConfig { }
|
||||
|
@ -60,6 +60,7 @@ export interface NuxtApp {
|
||||
layouts: Record<string, NuxtLayout>
|
||||
middleware: NuxtMiddleware[]
|
||||
templates: NuxtTemplate[]
|
||||
configs: string[]
|
||||
}
|
||||
|
||||
type _TemplatePlugin = Omit<NuxtPlugin, 'src'> & NuxtTemplate
|
||||
|
@ -449,4 +449,28 @@ describe('dynamic paths', () => {
|
||||
).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
6
test/fixtures/basic/app.config.ts
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
export default defineAppConfig({
|
||||
userConfig: 123,
|
||||
nested: {
|
||||
val: 2
|
||||
}
|
||||
})
|
3
test/fixtures/basic/extends/bar/app.config.ts
vendored
Normal file
3
test/fixtures/basic/extends/bar/app.config.ts
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
export default {
|
||||
fromLayer: true
|
||||
}
|
6
test/fixtures/basic/nuxt.config.ts
vendored
6
test/fixtures/basic/nuxt.config.ts
vendored
@ -37,5 +37,11 @@ export default defineNuxtConfig({
|
||||
experimental: {
|
||||
reactivityTransform: true,
|
||||
treeshakeClientOnly: true
|
||||
},
|
||||
appConfig: {
|
||||
fromNuxtConfig: true,
|
||||
nested: {
|
||||
val: 1
|
||||
}
|
||||
}
|
||||
})
|
||||
|
11
test/fixtures/basic/pages/app-config.vue
vendored
Normal file
11
test/fixtures/basic/pages/app-config.vue
vendored
Normal 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>
|
15
test/fixtures/basic/types.ts
vendored
15
test/fixtures/basic/types.ts
vendored
@ -1,6 +1,7 @@
|
||||
import { expectTypeOf } from 'expect-type'
|
||||
import { describe, it } from 'vitest'
|
||||
import type { Ref } from 'vue'
|
||||
import type { AppConfig } from '@nuxt/schema'
|
||||
|
||||
import { NavigationFailure, RouteLocationNormalizedLoaded, RouteLocationRaw, useRouter as vueUseRouter } from 'vue-router'
|
||||
import { defineNuxtConfig } from '~~/../../../packages/nuxt/src'
|
||||
@ -165,3 +166,17 @@ describe('composables', () => {
|
||||
.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>()
|
||||
})
|
||||
})
|
||||
|
Loading…
Reference in New Issue
Block a user