Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

perf(nuxt): extract and apply plugin order at build time #21611

Merged
merged 16 commits into from Jun 19, 2023
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/nuxt/package.json
Expand Up @@ -89,6 +89,7 @@
"prompts": "^2.4.2",
"scule": "^1.0.0",
"strip-literal": "^1.0.1",
"typescript-estree": "^18.1.0",
"ufo": "^1.1.2",
"ultrahtml": "^1.2.0",
"uncrypto": "^0.1.3",
Expand Down
6 changes: 2 additions & 4 deletions packages/nuxt/src/app/entry.ts
Expand Up @@ -8,11 +8,11 @@ import type { $Fetch, NitroFetchRequest } from 'nitropack'
import { baseURL } from '#build/paths.mjs'

import type { CreateOptions } from '#app'
import { applyPlugins, createNuxtApp, normalizePlugins } from '#app/nuxt'
import { applyPlugins, createNuxtApp } from '#app/nuxt'

import '#build/css'
// @ts-expect-error virtual file
import _plugins from '#build/plugins'
import plugins from '#build/plugins'
// @ts-expect-error virtual file
import RootComponent from '#build/root-component.mjs'
// @ts-expect-error virtual file
Expand All @@ -26,8 +26,6 @@ if (!globalThis.$fetch) {

let entry: Function

const plugins = normalizePlugins(_plugins)

if (process.server) {
entry = async function createNuxtAppServer (ssrContext: CreateOptions['ssrContext']) {
const vueApp = createApp(RootComponent)
Expand Down
75 changes: 4 additions & 71 deletions packages/nuxt/src/app/nuxt.ts
Expand Up @@ -160,7 +160,6 @@ export interface PluginMeta {

export interface ResolvedPluginMeta {
name?: string
order: number
parallel?: boolean
}

Expand Down Expand Up @@ -324,70 +323,6 @@ export async function applyPlugins (nuxtApp: NuxtApp, plugins: Plugin[]) {
if (errors.length) { throw errors[0] }
}

export function normalizePlugins (_plugins: Plugin[]) {
const unwrappedPlugins: Plugin[] = []
const legacyInjectPlugins: Plugin[] = []
const invalidPlugins: Plugin[] = []

const plugins: Plugin[] = []

for (const plugin of _plugins) {
if (typeof plugin !== 'function') {
if (process.dev) { invalidPlugins.push(plugin) }
continue
}

// TODO: Skip invalid plugins in next releases
let _plugin = plugin
if (plugin.length > 1) {
// Allow usage without wrapper but warn
if (process.dev) { legacyInjectPlugins.push(plugin) }
// @ts-expect-error deliberate invalid second argument
_plugin = (nuxtApp: NuxtApp) => plugin(nuxtApp, nuxtApp.provide)
}

// Allow usage without wrapper but warn
if (process.dev && !isNuxtPlugin(_plugin)) { unwrappedPlugins.push(_plugin) }

plugins.push(_plugin)
}

plugins.sort((a, b) => (a.meta?.order || orderMap.default) - (b.meta?.order || orderMap.default))

if (process.dev && legacyInjectPlugins.length) {
console.warn('[warn] [nuxt] You are using a plugin with legacy Nuxt 2 format (context, inject) which is likely to be broken. In the future they will be ignored:', legacyInjectPlugins.map(p => p.name || p).join(','))
}
if (process.dev && invalidPlugins.length) {
console.warn('[warn] [nuxt] Some plugins are not exposing a function and skipped:', invalidPlugins)
}
if (process.dev && unwrappedPlugins.length) {
console.warn('[warn] [nuxt] You are using a plugin that has not been wrapped in `defineNuxtPlugin`. It is advised to wrap your plugins as in the future this may enable enhancements:', unwrappedPlugins.map(p => p.name || p).join(','))
}

return plugins
}

// -50: pre-all (nuxt)
// -40: custom payload revivers (user)
// -30: payload reviving (nuxt)
// -20: pre (user) <-- pre mapped to this
// -10: default (nuxt)
// 0: default (user) <-- default behavior
// +10: post (nuxt)
// +20: post (user) <-- post mapped to this
// +30: post-all (nuxt)

const orderMap: Record<NonNullable<ObjectPluginInput['enforce']>, number> = {
pre: -20,
default: 0,
post: 20
}

/*! @__NO_SIDE_EFFECTS__ */
export function definePayloadPlugin<T extends Record<string, unknown>> (plugin: Plugin<T> | ObjectPluginInput<T>) {
return defineNuxtPlugin(plugin, { order: -40 })
}

/*! @__NO_SIDE_EFFECTS__ */
export function defineNuxtPlugin<T extends Record<string, unknown>> (plugin: Plugin<T> | ObjectPluginInput<T>, meta?: PluginMeta): Plugin<T> {
if (typeof plugin === 'function') { return defineNuxtPlugin({ setup: plugin }, meta) }
Expand All @@ -403,19 +338,17 @@ export function defineNuxtPlugin<T extends Record<string, unknown>> (plugin: Plu

wrapper.meta = {
name: meta?.name || plugin.name || plugin.setup?.name,
parallel: plugin.parallel,
order:
meta?.order ||
plugin.order ||
orderMap[plugin.enforce || 'default'] ||
orderMap.default
parallel: plugin.parallel
}

wrapper[NuxtPluginIndicator] = true

return wrapper
}

/*! @__NO_SIDE_EFFECTS__ */
export const definePayloadPlugin = defineNuxtPlugin

export function isNuxtPlugin (plugin: unknown) {
return typeof plugin === 'function' && NuxtPluginIndicator in plugin
}
Expand Down
19 changes: 19 additions & 0 deletions packages/nuxt/src/core/app.ts
Expand Up @@ -6,6 +6,7 @@ import type { Nuxt, NuxtApp, NuxtPlugin, NuxtTemplate, ResolvedNuxtTemplate } fr

import * as defaultTemplates from './templates'
import { getNameFromPath, hasSuffix, uniqueBy } from './utils'
import { extractMetadata, orderMap } from './plugins/plugin-metadata'

export function createApp (nuxt: Nuxt, options: Partial<NuxtApp> = {}): NuxtApp {
return defu(options, {
Expand Down Expand Up @@ -149,3 +150,21 @@ function resolvePaths<Item extends Record<string, any>> (items: Item[], key: { [
}
}))
}

export async function annotatePlugins (nuxt: Nuxt, plugins: NuxtPlugin[]) {
const _plugins: NuxtPlugin[] = []
for (const plugin of plugins) {
try {
const code = plugin.src in nuxt.vfs ? nuxt.vfs[plugin.src] : await fsp.readFile(plugin.src!, 'utf-8')
_plugins.push({
...extractMetadata(code),
...plugin
})
} catch (e) {
console.warn(`[nuxt] Could not resolve \`${plugin.src}\`.`)
_plugins.push(plugin)
}
}

return _plugins.sort((a, b) => (a.order ?? orderMap.default) - (b.order ?? orderMap.default))
}
6 changes: 5 additions & 1 deletion packages/nuxt/src/core/nuxt.ts
@@ -1,7 +1,7 @@
import { join, normalize, relative, resolve } from 'pathe'
import { createDebugger, createHooks } from 'hookable'
import type { LoadNuxtOptions } from '@nuxt/kit'
import { addComponent, addPlugin, addVitePlugin, addWebpackPlugin, installModule, loadNuxtConfig, logger, nuxtCtx, resolveAlias, resolveFiles, resolvePath, tryResolveModule } from '@nuxt/kit'
import { addBuildPlugin, addComponent, addPlugin, addVitePlugin, addWebpackPlugin, installModule, loadNuxtConfig, logger, nuxtCtx, resolveAlias, resolveFiles, resolvePath, tryResolveModule } from '@nuxt/kit'
import type { Nuxt, NuxtHooks, NuxtOptions } from 'nuxt/schema'

import escapeRE from 'escape-string-regexp'
Expand All @@ -24,6 +24,7 @@ import { LayerAliasingPlugin } from './plugins/layer-aliasing'
import { addModuleTranspiles } from './modules'
import { initNitro } from './nitro'
import schemaModule from './schema'
import { RemovePluginMetadataPlugin } from './plugins/plugin-metadata'

export function createNuxt (options: NuxtOptions): Nuxt {
const hooks = createHooks<NuxtHooks>()
Expand Down Expand Up @@ -72,6 +73,9 @@ async function initNuxt (nuxt: Nuxt) {
}
})

// Add plugin normalisation plugin
addBuildPlugin(RemovePluginMetadataPlugin(nuxt))

// Add import protection
const config = {
rootDir: nuxt.options.rootDir,
Expand Down
176 changes: 176 additions & 0 deletions packages/nuxt/src/core/plugins/plugin-metadata.ts
@@ -0,0 +1,176 @@
import type { CallExpression, Property, SpreadElement } from 'estree'
import type { Node } from 'estree-walker'
import { walk } from 'estree-walker'
import { parse } from 'typescript-estree'
import { defu } from 'defu'
import { findExports } from 'mlly'
import type { Nuxt } from '@nuxt/schema'
import { createUnplugin } from 'unplugin'
import MagicString from 'magic-string'

// eslint-disable-next-line import/no-restricted-paths
import type { ObjectPluginInput, PluginMeta } from '#app'

// -50: pre-all (nuxt)
// -40: custom payload revivers (user)
// -30: payload reviving (nuxt)
// -20: pre (user) <-- pre mapped to this
// -10: default (nuxt)
// 0: default (user) <-- default behavior
// +10: post (nuxt)
// +20: post (user) <-- post mapped to this
// +30: post-all (nuxt)

export const orderMap: Record<NonNullable<ObjectPluginInput['enforce']>, number> = {
pre: -20,
default: 0,
post: 20
}

const metaCache: Record<string, Omit<PluginMeta, 'enforce'>> = {}
export function extractMetadata (code: string) {
let meta: PluginMeta = {}
if (metaCache[code]) {
return metaCache[code]
}
walk(parse(code) as Node, {
enter (_node) {
if (_node.type !== 'CallExpression' || (_node as CallExpression).callee.type !== 'Identifier') { return }
const node = _node as CallExpression & { start: number, end: number }
const name = 'name' in node.callee && node.callee.name
if (name !== 'defineNuxtPlugin' && name !== 'definePayloadPlugin') { return }

if (name === 'definePayloadPlugin') {
meta.order = -40
danielroe marked this conversation as resolved.
Show resolved Hide resolved
}

const metaArg = node.arguments[1]
if (metaArg) {
if (metaArg.type !== 'ObjectExpression') {
throw new Error('Invalid plugin metadata')
}
meta = extractMetaFromObject(metaArg.properties)
}

const plugin = node.arguments[0]
if (plugin.type === 'ObjectExpression') {
meta = defu(extractMetaFromObject(plugin.properties), meta)
}

meta.order = meta.order || orderMap[meta.enforce || 'default'] || orderMap.default
delete meta.enforce
}
})
metaCache[code] = meta
return meta as Omit<PluginMeta, 'enforce'>
}

type PluginMetaKey = keyof PluginMeta
const keys: Record<PluginMetaKey, string> = {
name: 'name',
order: 'order',
enforce: 'enforce'
}
function isMetadataKey (key: string): key is PluginMetaKey {
return key in keys
}

function extractMetaFromObject (properties: Array<Property | SpreadElement>) {
const meta: PluginMeta = {}
for (const property of properties) {
if (property.type === 'SpreadElement' || !('name' in property.key)) {
throw new Error('Invalid plugin metadata')
}
const propertyKey = property.key.name
if (!isMetadataKey(propertyKey)) { continue }
if (property.value.type === 'Literal') {
meta[propertyKey] = property.value.value as any
}
if (property.value.type === 'UnaryExpression' && property.value.argument.type === 'Literal') {
meta[propertyKey] = JSON.parse(property.value.operator + property.value.argument.raw!)
}
}
return meta
}

export const RemovePluginMetadataPlugin = (nuxt: Nuxt) => createUnplugin(() => {
danielroe marked this conversation as resolved.
Show resolved Hide resolved
return {
name: 'nuxt:remove-plugin-metadata',
enforce: 'pre',
transform (code, id) {
const plugin = nuxt.apps.default.plugins.find(p => p.src === id)
if (!plugin) { return }

const s = new MagicString(code)
const exports = findExports(code)
const defaultExport = exports.find(e => e.type === 'default' || e.name === 'default')
if (!defaultExport) {
console.error(`[warn] [nuxt] Plugin \`${plugin.src}\` has no default export and will be ignored at build time. Add \`export default defineNuxtPlugin(() => {})\` to your plugin.`)
s.overwrite(0, code.length, 'export default {}')
return {
code: s.toString(),
map: nuxt.options.sourcemap.client || nuxt.options.sourcemap.server ? s.generateMap({ hires: true }) : null
}
}

let wrapped = false

try {
walk(parse(code) as Node, {
enter (_node) {
if (_node.type === 'ExportDefaultDeclaration' && _node.declaration.type === 'FunctionDeclaration') {
danielroe marked this conversation as resolved.
Show resolved Hide resolved
if ('params' in _node.declaration && _node.declaration.params.length > 1) {
console.warn(`[warn] [nuxt] Plugin \`${plugin.src}\` is in legacy Nuxt 2 format (context, inject) which is likely to be broken and will be ignored.`)
s.overwrite(0, code.length, 'export default {}')
wrapped = true // silence a duplicate error
return
}
}
if (_node.type !== 'CallExpression' || (_node as CallExpression).callee.type !== 'Identifier') { return }
const node = _node as CallExpression & { start: number, end: number }
const name = 'name' in node.callee && node.callee.name
if (name !== 'defineNuxtPlugin' && name !== 'definePayloadPlugin') { return }
wrapped = true

// TODO: Warn if legacy plugin format is detected
if (node.arguments[0].type !== 'ObjectExpression') {
if ('params' in node.arguments[0] && node.arguments[0].params.length > 1) {
console.warn(`[warn] [nuxt] Plugin \`${plugin.src}\` is in legacy Nuxt 2 format (context, inject) which is likely to be broken and will be ignored.`)
s.overwrite(0, code.length, 'export default {}')
return
}
}

// Remove metadata that already has been extracted
if (!('order' in plugin)) { return }
for (const [argIndex, arg] of node.arguments.entries()) {
if (arg.type !== 'ObjectExpression') { continue }
for (const [propertyIndex, property] of arg.properties.entries()) {
if (property.type === 'SpreadElement' || !('name' in property.key)) { continue }
const propertyKey = property.key.name
if (propertyKey === 'order' || propertyKey === 'enforce') {
const nextIndex = arg.properties[propertyIndex + 1]?.range?.[0] || node.arguments[argIndex + 1]?.range?.[0] || (arg.range![1] - 1)
s.remove(property.range![0], nextIndex)
}
}
}
}
})
} catch (e) {
console.error(e)
return
}

if (!wrapped) {
console.warn(`[warn] [nuxt] Plugin \`${plugin.src}\` is not wrapped in \`defineNuxtPlugin\`. It is advised to wrap your plugins as in the future this may enable enhancements.`)
}

if (s.hasChanged()) {
return {
code: s.toString(),
map: nuxt.options.sourcemap.client || nuxt.options.sourcemap.server ? s.generateMap({ hires: true }) : null
}
}
}
}
})
10 changes: 6 additions & 4 deletions packages/nuxt/src/core/templates.ts
Expand Up @@ -8,6 +8,7 @@ import { camelCase } from 'scule'
import { resolvePath } from 'mlly'
import { filename } from 'pathe/utils'
import type { Nuxt, NuxtApp, NuxtTemplate } from 'nuxt/schema'
import { annotatePlugins } from './app'

export interface TemplateContext {
nuxt: Nuxt
Expand Down Expand Up @@ -54,8 +55,9 @@ export const cssTemplate: NuxtTemplate<TemplateContext> = {

export const clientPluginTemplate: NuxtTemplate<TemplateContext> = {
filename: 'plugins/client.mjs',
getContents (ctx) {
const clientPlugins = ctx.app.plugins.filter(p => !p.mode || p.mode !== 'server')
async getContents (ctx) {
const clientPlugins = await annotatePlugins(ctx.nuxt, ctx.app.plugins.filter(p => !p.mode || p.mode !== 'server'))
await annotatePlugins(ctx.nuxt, clientPlugins)
const exports: string[] = []
const imports: string[] = []
for (const plugin of clientPlugins) {
Expand All @@ -73,8 +75,8 @@ export const clientPluginTemplate: NuxtTemplate<TemplateContext> = {

export const serverPluginTemplate: NuxtTemplate<TemplateContext> = {
filename: 'plugins/server.mjs',
getContents (ctx) {
const serverPlugins = ctx.app.plugins.filter(p => !p.mode || p.mode !== 'client')
async getContents (ctx) {
const serverPlugins = await annotatePlugins(ctx.nuxt, ctx.app.plugins.filter(p => !p.mode || p.mode !== 'client'))
const exports: string[] = []
const imports: string[] = []
for (const plugin of serverPlugins) {
Expand Down