feat(nuxt): allow plugins to specify dependencies (#24127)

This commit is contained in:
Julien Huang 2023-12-14 18:11:08 +01:00 committed by GitHub
parent 02306fd13d
commit 5877e11c89
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 466 additions and 31 deletions

View File

@ -101,9 +101,11 @@ In case you're new to 'alphabetical' numbering, remember that filenames are sort
## Loading Strategy
### Parallel Plugins
By default, Nuxt loads plugins sequentially. You can define a plugin as `parallel` so Nuxt won't wait the end of the plugin's execution before loading the next plugin.
```ts [plugins/hello.ts]
```ts [plugins/my-plugin.ts]
export default defineNuxtPlugin({
name: 'my-plugin',
parallel: true,
@ -113,6 +115,20 @@ export default defineNuxtPlugin({
})
```
### Plugins With Dependencies
If a plugin needs to await a parallel plugin before it runs, you can add the plugin's name to the `dependsOn` array.
```ts [plugins/depending-on-my-plugin.ts]
export default defineNuxtPlugin({
name: 'depends-on-my-plugin',
dependsOn: ['my-plugin']
async setup (nuxtApp) {
// this plugin will wait for the end of `my-plugin`'s execution before it runs
}
})
```
## Using Composables
You can use [composables](/docs/guide/directory-structure/composables) as well as [utils](/docs/guide/directory-structure/utils) within Nuxt plugins:

View File

@ -7,9 +7,7 @@ export * from './composables/index'
export * from './components/index'
export * from './config'
export * from './compat/idle-callback'
// eslint-disable-next-line import/no-restricted-paths
export type { PageMeta } from '../pages/runtime/index'
export * from './types'
export const isVue2 = false
export const isVue3 = true

View File

@ -18,6 +18,8 @@ import type { NuxtError } from '../app/composables/error'
import type { AsyncDataRequestStatus } from '../app/composables/asyncData'
import type { NuxtAppManifestMeta } from '../app/composables/manifest'
import type { NuxtAppLiterals } from '#app'
const nuxtAppCtx = /*@__PURE__*/ getContext<NuxtApp>('nuxt-app', {
asyncContext: !!__NUXT_ASYNC_CONTEXT__ && import.meta.server
})
@ -154,6 +156,10 @@ export const NuxtPluginIndicator = '__nuxt_plugin'
export interface PluginMeta {
name?: string
enforce?: 'pre' | 'default' | 'post'
/**
* Await for other named plugins to finish before running this plugin.
*/
dependsOn?: NuxtAppLiterals['pluginName'][]
/**
* This allows more granular control over plugin order and should only be used by advanced users.
* It overrides the value of `enforce` and is used to sort plugins.
@ -190,6 +196,10 @@ export interface ObjectPlugin<Injections extends Record<string, unknown> = Recor
* @default false
*/
parallel?: boolean
/**
* @internal
*/
_name?: string
}
/** @deprecated Use `ObjectPlugin` */
@ -331,26 +341,61 @@ export async function applyPlugin (nuxtApp: NuxtApp, plugin: Plugin & ObjectPlug
}
export async function applyPlugins (nuxtApp: NuxtApp, plugins: Array<Plugin & ObjectPlugin<any>>) {
const resolvedPlugins: string[] = []
const unresolvedPlugins: [Set<string>, Plugin & ObjectPlugin<any>][] = []
const parallels: Promise<any>[] = []
const errors: Error[] = []
for (const plugin of plugins) {
if (import.meta.server && nuxtApp.ssrContext?.islandContext && plugin.env?.islands === false) { continue }
const promise = applyPlugin(nuxtApp, plugin)
if (plugin.parallel) {
parallels.push(promise.catch(e => errors.push(e)))
let promiseDepth = 0
async function executePlugin (plugin: Plugin & ObjectPlugin<any>) {
if (plugin.dependsOn && !plugin.dependsOn.every(name => resolvedPlugins.includes(name))) {
unresolvedPlugins.push([new Set(plugin.dependsOn), plugin])
} else {
await promise
const promise = applyPlugin(nuxtApp, plugin).then(async () => {
if (plugin._name) {
resolvedPlugins.push(plugin._name)
await Promise.all(unresolvedPlugins.map(async ([dependsOn, unexecutedPlugin]) => {
if (dependsOn.has(plugin._name!)) {
dependsOn.delete(plugin._name!)
if (dependsOn.size === 0) {
promiseDepth++
await executePlugin(unexecutedPlugin)
}
}
}))
}
})
if (plugin.parallel) {
parallels.push(promise.catch(e => errors.push(e)))
} else {
await promise
}
}
}
for (const plugin of plugins) {
if (import.meta.server && nuxtApp.ssrContext?.islandContext && plugin.env?.islands === false) { continue }
await executePlugin(plugin)
}
await Promise.all(parallels)
if (promiseDepth) {
for (let i = 0; i < promiseDepth; i++) {
await Promise.all(parallels)
}
}
if (errors.length) { throw errors[0] }
}
/*@__NO_SIDE_EFFECTS__*/
export function defineNuxtPlugin<T extends Record<string, unknown>> (plugin: Plugin<T> | ObjectPlugin<T>): Plugin<T> & ObjectPlugin<T> {
if (typeof plugin === 'function') { return plugin }
const _name = plugin._name || plugin.name
delete plugin.name
return Object.assign(plugin.setup || (() => {}), plugin, { [NuxtPluginIndicator]: true } as const)
return Object.assign(plugin.setup || (() => {}), plugin, { [NuxtPluginIndicator]: true, _name } as const)
}
/*@__NO_SIDE_EFFECTS__*/

View File

@ -0,0 +1,6 @@
// eslint-disable-next-line import/no-restricted-paths
export type { PageMeta } from '../pages/runtime/index'
export interface NuxtAppLiterals {
[key: string]: string
}

View File

@ -8,6 +8,8 @@ import * as defaultTemplates from './templates'
import { getNameFromPath, hasSuffix, uniqueBy } from './utils'
import { extractMetadata, orderMap } from './plugins/plugin-metadata'
import type { PluginMeta } from '#app'
export function createApp (nuxt: Nuxt, options: Partial<NuxtApp> = {}): NuxtApp {
return defu(options, {
dir: nuxt.options.srcDir,
@ -185,7 +187,7 @@ function resolvePaths<Item extends Record<string, any>> (items: Item[], key: { [
}
export async function annotatePlugins (nuxt: Nuxt, plugins: NuxtPlugin[]) {
const _plugins: NuxtPlugin[] = []
const _plugins: Array<NuxtPlugin & Omit<PluginMeta, 'enforce'>> = []
for (const plugin of plugins) {
try {
const code = plugin.src in nuxt.vfs ? nuxt.vfs[plugin.src] : await fsp.readFile(plugin.src!, 'utf-8')
@ -201,3 +203,29 @@ export async function annotatePlugins (nuxt: Nuxt, plugins: NuxtPlugin[]) {
return _plugins.sort((a, b) => (a.order ?? orderMap.default) - (b.order ?? orderMap.default))
}
export function checkForCircularDependencies (_plugins: Array<NuxtPlugin & Omit<PluginMeta, 'enforce'>>) {
const deps: Record<string, string[]> = Object.create(null)
const pluginNames = _plugins.map(plugin => plugin.name)
for (const plugin of _plugins) {
// Make sure dependency plugins are registered
if (plugin.dependsOn && plugin.dependsOn.some(name => !pluginNames.includes(name))) {
console.error(`Plugin \`${plugin.name}\` depends on \`${plugin.dependsOn.filter(name => !pluginNames.includes(name)).join(', ')}\` but they are not registered.`)
}
// Make graph to detect circular dependencies
if (plugin.name) {
deps[plugin.name] = plugin.dependsOn || []
}
}
const checkDeps = (name: string, visited: string[] = []): string[] => {
if (visited.includes(name)) {
console.error(`Circular dependency detected in plugins: ${visited.join(' -> ')} -> ${name}`)
return []
}
visited.push(name)
return (deps[name] || []).flatMap(dep => checkDeps(dep, [...visited]))
}
for (const name in deps) {
checkDeps(name)
}
}

View File

@ -1,4 +1,4 @@
import type { CallExpression, Property, SpreadElement } from 'estree'
import type { CallExpression, Literal, Property, SpreadElement } from 'estree'
import type { Node } from 'estree-walker'
import { walk } from 'estree-walker'
import { transform } from 'esbuild'
@ -87,7 +87,8 @@ type PluginMetaKey = keyof PluginMeta
const keys: Record<PluginMetaKey, string> = {
name: 'name',
order: 'order',
enforce: 'enforce'
enforce: 'enforce',
dependsOn: 'dependsOn'
}
function isMetadataKey (key: string): key is PluginMetaKey {
return key in keys
@ -107,6 +108,12 @@ function extractMetaFromObject (properties: Array<Property | SpreadElement>) {
if (property.value.type === 'UnaryExpression' && property.value.argument.type === 'Literal') {
meta[propertyKey] = JSON.parse(property.value.operator + property.value.argument.raw!)
}
if (propertyKey === 'dependsOn' && property.value.type === 'ArrayExpression') {
if (property.value.elements.some(e => !e || e.type !== 'Literal' || typeof e.value !== 'string')) {
throw new Error('dependsOn must take an array of string literals')
}
meta[propertyKey] = property.value.elements.map(e => (e as Literal)!.value as string)
}
}
return meta
}

View File

@ -8,7 +8,7 @@ import { hash } from 'ohash'
import { camelCase } from 'scule'
import { filename } from 'pathe/utils'
import type { Nuxt, NuxtApp, NuxtTemplate } from 'nuxt/schema'
import { annotatePlugins } from './app'
import { annotatePlugins, checkForCircularDependencies } from './app'
interface TemplateContext {
nuxt: Nuxt
@ -62,7 +62,7 @@ export const clientPluginTemplate: NuxtTemplate<TemplateContext> = {
filename: 'plugins/client.mjs',
async getContents (ctx) {
const clientPlugins = await annotatePlugins(ctx.nuxt, ctx.app.plugins.filter(p => !p.mode || p.mode !== 'server'))
await annotatePlugins(ctx.nuxt, clientPlugins)
checkForCircularDependencies(clientPlugins)
const exports: string[] = []
const imports: string[] = []
for (const plugin of clientPlugins) {
@ -82,6 +82,7 @@ export const serverPluginTemplate: NuxtTemplate<TemplateContext> = {
filename: 'plugins/server.mjs',
async getContents (ctx) {
const serverPlugins = await annotatePlugins(ctx.nuxt, ctx.app.plugins.filter(p => !p.mode || p.mode !== 'client'))
checkForCircularDependencies(serverPlugins)
const exports: string[] = []
const imports: string[] = []
for (const plugin of serverPlugins) {
@ -99,7 +100,7 @@ export const serverPluginTemplate: NuxtTemplate<TemplateContext> = {
export const pluginsDeclaration: NuxtTemplate<TemplateContext> = {
filename: 'types/plugins.d.ts',
getContents: (ctx) => {
getContents: async (ctx) => {
const EXTENSION_RE = new RegExp(`(?<=\\w)(${ctx.nuxt.options.extensions.map(e => escapeRE(e)).join('|')})$`, 'g')
const tsImports: string[] = []
for (const p of ctx.app.plugins) {
@ -111,6 +112,8 @@ export const pluginsDeclaration: NuxtTemplate<TemplateContext> = {
}
}
const pluginsName = (await annotatePlugins(ctx.nuxt, ctx.app.plugins)).filter(p => p.name).map(p => `'${p.name}'`)
return `// Generated by Nuxt'
import type { Plugin } from '#app'
@ -122,6 +125,10 @@ type NuxtAppInjections = \n ${tsImports.map(p => `InjectionType<typeof ${genDyn
declare module '#app' {
interface NuxtApp extends NuxtAppInjections { }
interface NuxtAppLiterals {
pluginName: ${pluginsName.join(' | ')}
}
}
declare module 'vue' {

View File

@ -1,7 +1,8 @@
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { parse } from 'acorn'
import { RemovePluginMetadataPlugin, extractMetadata } from '../src/core/plugins/plugin-metadata'
import { checkForCircularDependencies } from '../src/core/app'
describe('plugin-metadata', () => {
it('should extract metadata from object-syntax plugins', async () => {
@ -63,3 +64,51 @@ describe('plugin-metadata', () => {
`)
})
})
describe('plugin sanity checking', () => {
it('non-existent depends are warned', () => {
vi.spyOn(console, 'error')
checkForCircularDependencies([
{
name: 'A',
src: ''
},
{
name: 'B',
dependsOn: ['D'],
src: ''
},
{
name: 'C',
src: ''
}
])
expect(console.error).toBeCalledWith('Plugin `B` depends on `D` but they are not registered.')
vi.restoreAllMocks()
})
it('circular dependencies are warned', () => {
vi.spyOn(console, 'error')
checkForCircularDependencies([
{
name: 'A',
dependsOn: ['B'],
src: ''
},
{
name: 'B',
dependsOn: ['C'],
src: ''
},
{
name: 'C',
dependsOn: ['A'],
src: ''
}
])
expect(console.error).toBeCalledWith('Circular dependency detected in plugins: A -> B -> C -> A')
expect(console.error).toBeCalledWith('Circular dependency detected in plugins: B -> C -> A -> B')
expect(console.error).toBeCalledWith('Circular dependency detected in plugins: C -> A -> B -> C')
vi.restoreAllMocks()
})
})

View File

@ -16,6 +16,10 @@ export interface NuxtPlugin {
* Default Nuxt priorities can be seen at [here](https://github.com/nuxt/nuxt/blob/9904849bc87c53dfbd3ea3528140a5684c63c8d8/packages/nuxt/src/core/plugins/plugin-metadata.ts#L15-L34).
*/
order?: number
/**
* @internal
*/
name?: string
}
// Internal type for simpler NuxtTemplate interface extension

View File

@ -32,7 +32,7 @@ describe.skipIf(process.env.SKIP_BUNDLE_SIZE === 'true' || process.env.ECOSYSTEM
const serverDir = join(rootDir, '.output/server')
const serverStats = await analyzeSizes(['**/*.mjs', '!node_modules'], serverDir)
expect.soft(roundToKilobytes(serverStats.totalBytes)).toMatchInlineSnapshot('"199k"')
expect.soft(roundToKilobytes(serverStats.totalBytes)).toMatchInlineSnapshot(`"200k"`)
const modules = await analyzeSizes('node_modules/**/*', serverDir)
expect.soft(roundToKilobytes(modules.totalBytes)).toMatchInlineSnapshot('"1847k"')
@ -71,7 +71,7 @@ describe.skipIf(process.env.SKIP_BUNDLE_SIZE === 'true' || process.env.ECOSYSTEM
const serverDir = join(rootDir, '.output-inline/server')
const serverStats = await analyzeSizes(['**/*.mjs', '!node_modules'], serverDir)
expect.soft(roundToKilobytes(serverStats.totalBytes)).toMatchInlineSnapshot('"510k"')
expect.soft(roundToKilobytes(serverStats.totalBytes)).toMatchInlineSnapshot(`"511k"`)
const modules = await analyzeSizes('node_modules/**/*', serverDir)
expect.soft(roundToKilobytes(modules.totalBytes)).toMatchInlineSnapshot('"77.0k"')

View File

@ -242,6 +242,18 @@ describe('nuxtApp', () => {
})
})
describe('plugins', () => {
it('dependsOn is strongly typed', () => {
defineNuxtPlugin({
// @ts-expect-error invalid plugin name
dependsOn: ['something']
})
defineNuxtPlugin({
dependsOn: ['nuxt:router']
})
})
})
describe('runtimeConfig', () => {
it('generated runtimeConfig types', () => {
const runtimeConfig = useRuntimeConfig()

View File

@ -1,13 +1,17 @@
export default defineNuxtPlugin(async (/* nuxtApp */) => {
const config1 = useRuntimeConfig()
await new Promise(resolve => setTimeout(resolve, 100))
const { data } = useFetch('/api/hey', { key: 'hey' })
const config2 = useRuntimeConfig()
return {
provide: {
asyncPlugin: () => config1 && config1 === config2
? 'Async plugin works! ' + config1.public.testConfig + (data.value?.baz ? 'useFetch works!' : 'useFetch does not work')
: 'Async plugin failed!'
export default defineNuxtPlugin({
name: 'async-plugin',
async setup (/* nuxtApp */) {
const config1 = useRuntimeConfig()
await new Promise(resolve => setTimeout(resolve, 100))
const { data } = useFetch('/api/hey', { key: 'hey' })
const config2 = useRuntimeConfig()
return {
provide: {
asyncPlugin: () => config1 && config1 === config2
? 'Async plugin works! ' + config1.public.testConfig + (data.value?.baz ? 'useFetch works!' : 'useFetch does not work')
: 'Async plugin failed!'
}
}
}
},
parallel: true
})

View File

@ -0,0 +1,12 @@
export default defineNuxtPlugin({
name: 'depends-on-plugin',
dependsOn: ['async-plugin'],
async setup () {
const nuxtApp = useNuxtApp()
if (!nuxtApp.$asyncPlugin) {
throw new Error('$asyncPlugin is not defined')
}
await new Promise(resolve => setTimeout(resolve, 100))
},
parallel: true
})

247
test/nuxt/plugin.test.ts Normal file
View File

@ -0,0 +1,247 @@
import { describe, expect, it, vi } from 'vitest'
import { applyPlugins } from '#app/nuxt'
import { defineNuxtPlugin } from '#app'
vi.mock('#app', async (original) => {
return {
...(await original<typeof import('#app')>()),
applyPlugin: vi.fn(async (_nuxtApp, plugin) => {
await plugin()
})
}
})
function pluginFactory (name: string, dependsOn?: string[], sequence: string[], parallel = true) {
return defineNuxtPlugin({
name,
dependsOn,
async setup () {
sequence.push(`start ${name}`)
await new Promise(resolve => setTimeout(resolve, 10))
sequence.push(`end ${name}`)
},
parallel
})
}
describe('plugin dependsOn', () => {
it('expect B to await A to finish before being run', async () => {
const nuxtApp = useNuxtApp()
const sequence: string[] = []
const plugins = [
pluginFactory('A', undefined, sequence),
pluginFactory('B', ['A'], sequence)
]
await applyPlugins(nuxtApp, plugins)
expect(sequence).toMatchObject([
'start A',
'end A',
'start B',
'end B'
])
})
it('expect C to await A and B to finish before being run', async () => {
const nuxtApp = useNuxtApp()
const sequence: string[] = []
const plugins = [
pluginFactory('A', undefined, sequence),
pluginFactory('B', ['A'], sequence),
pluginFactory('C', ['A', 'B'], sequence)
]
await applyPlugins(nuxtApp, plugins)
expect(sequence).toMatchObject([
'start A',
'end A',
'start B',
'end B',
'start C',
'end C'
])
})
it('expect C to not wait for A to finish before being run', async () => {
const nuxtApp = useNuxtApp()
const sequence: string[] = []
const plugins = [
pluginFactory('A', undefined, sequence),
pluginFactory('B', ['A'], sequence),
defineNuxtPlugin({
name,
async setup () {
sequence.push('start C')
await new Promise(resolve => setTimeout(resolve, 5))
sequence.push('end C')
},
parallel: true
})
]
await applyPlugins(nuxtApp, plugins)
expect(sequence).toMatchObject([
'start A',
'start C',
'end C',
'end A',
'start B',
'end B'
])
})
it('expect C to block the depends on of A-B since C is sequential', async () => {
const nuxtApp = useNuxtApp()
const sequence: string[] = []
const plugins = [
pluginFactory('A', undefined, sequence),
defineNuxtPlugin({
name,
async setup () {
sequence.push('start C')
await new Promise(resolve => setTimeout(resolve, 50))
sequence.push('end C')
}
}),
pluginFactory('B', ['A'], sequence)
]
await applyPlugins(nuxtApp, plugins)
expect(sequence).toMatchObject([
'start A',
'start C',
'end A',
'end C',
'start B',
'end B'
])
})
it('relying on plugin not registed yet', async () => {
const nuxtApp = useNuxtApp()
const sequence: string[] = []
const plugins = [
pluginFactory('C', ['A'], sequence),
pluginFactory('A', undefined, sequence, true),
pluginFactory('E', ['B', 'C'], sequence, false),
pluginFactory('B', undefined, sequence),
pluginFactory('D', ['C'], sequence, false)
]
await applyPlugins(nuxtApp, plugins)
expect(sequence).toMatchObject([
'start A',
'start B',
'end A',
'start C',
'end B',
'end C',
'start E',
'start D',
'end E',
'end D'
])
})
it('test depending on not yet registered plugin and already resolved plugin', async () => {
const nuxtApp = useNuxtApp()
const sequence: string[] = []
const plugins = [
pluginFactory('A', undefined, sequence),
pluginFactory('B', ['A', 'C'], sequence),
pluginFactory('C', undefined, sequence, false),
pluginFactory('D', undefined, sequence, false),
pluginFactory('E', ['C'], sequence, false)
]
await applyPlugins(nuxtApp, plugins)
expect(sequence).toMatchObject([
'start A',
'start C',
'end A',
'end C',
'start B',
'start D',
'end B',
'end D',
'start E',
'end E'
])
})
it('multiple depth of plugin dependency', async () => {
const nuxtApp = useNuxtApp()
const sequence: string[] = []
const plugins = [
pluginFactory('A', undefined, sequence),
pluginFactory('C', ['B', 'A'], sequence),
pluginFactory('B', undefined, sequence, false),
pluginFactory('E', ['D'], sequence, false),
pluginFactory('D', ['C'], sequence, false)
]
await applyPlugins(nuxtApp, plugins)
expect(sequence).toMatchObject([
'start A',
'start B',
'end A',
'end B',
'start C',
'end C',
'start D',
'end D',
'start E',
'end E'
])
})
it('does not throw when circular dependency is not a problem', async () => {
const nuxtApp = useNuxtApp()
const sequence: string[] = []
const plugins = [
pluginFactory('A', ['B'], sequence),
pluginFactory('B', ['C'], sequence),
pluginFactory('C', ['D'], sequence),
pluginFactory('D', [], sequence),
]
await applyPlugins(nuxtApp, plugins)
expect(sequence).toMatchObject([
'start D',
'end D',
'start C',
'end C',
'start B',
'end B',
'start A',
'end A'
])
})
it('function plugin', async () => {
const nuxtApp = useNuxtApp()
const sequence: string[] = []
const plugins = [
pluginFactory('A', undefined, sequence),
defineNuxtPlugin(() => {
sequence.push('start C')
sequence.push('end C')
}),
pluginFactory('B', undefined, sequence, false)
]
await applyPlugins(nuxtApp, plugins)
expect(sequence).toMatchObject([
'start A',
'start C',
'end C',
'start B',
'end A',
'end B'
])
})
})