feat(nuxt): add nuxtMiddleware route rule (#25841)

This commit is contained in:
Horu 2024-03-17 01:53:01 +07:00 committed by GitHub
parent 79ea75e72a
commit f9fe282506
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 127 additions and 30 deletions

View File

@ -1,7 +1,8 @@
import type { MatcherExport, RouteMatcher } from 'radix3' import type { MatcherExport, RouteMatcher } from 'radix3'
import { createMatcherFromExport } from 'radix3' import { createMatcherFromExport, createRouter as createRadixRouter, toRouteMatcher } from 'radix3'
import { defu } from 'defu' import { defu } from 'defu'
import { useAppConfig } from '../config' import { useAppConfig } from '../config'
import { useRuntimeConfig } from '../nuxt'
// @ts-expect-error virtual file // @ts-expect-error virtual file
import { appManifest as isAppManifestEnabled } from '#build/nuxt.config.mjs' import { appManifest as isAppManifestEnabled } from '#build/nuxt.config.mjs'
// @ts-expect-error virtual file // @ts-expect-error virtual file
@ -43,6 +44,12 @@ export function getAppManifest (): Promise<NuxtAppManifest> {
/** @since 3.7.4 */ /** @since 3.7.4 */
export async function getRouteRules (url: string) { export async function getRouteRules (url: string) {
if (import.meta.server) {
const _routeRulesMatcher = toRouteMatcher(
createRadixRouter({ routes: useRuntimeConfig().nitro!.routeRules })
)
return defu({} as Record<string, any>, ..._routeRulesMatcher.matchAll(url).reverse())
}
await getAppManifest() await getAppManifest()
return defu({} as Record<string, any>, ...matcher.matchAll(url).reverse()) return defu({} as Record<string, any>, ...matcher.matchAll(url).reverse())
} }

View File

@ -3,11 +3,14 @@ import { computed, defineComponent, h, isReadonly, reactive } from 'vue'
import { isEqual, joinURL, parseQuery, parseURL, stringifyParsedURL, stringifyQuery, withoutBase } from 'ufo' import { isEqual, joinURL, parseQuery, parseURL, stringifyParsedURL, stringifyQuery, withoutBase } from 'ufo'
import { createError } from 'h3' import { createError } from 'h3'
import { defineNuxtPlugin, useRuntimeConfig } from '../nuxt' import { defineNuxtPlugin, useRuntimeConfig } from '../nuxt'
import { getRouteRules } from '../composables/manifest'
import { clearError, showError } from '../composables/error' import { clearError, showError } from '../composables/error'
import { navigateTo } from '../composables/router' import { navigateTo } from '../composables/router'
// @ts-expect-error virtual file // @ts-expect-error virtual file
import { globalMiddleware } from '#build/middleware' import { globalMiddleware } from '#build/middleware'
// @ts-expect-error virtual file
import { appManifest as isAppManifestEnabled } from '#build/nuxt.config.mjs'
interface Route { interface Route {
/** Percentage encoded pathname section of the URL. */ /** Percentage encoded pathname section of the URL. */
@ -243,6 +246,23 @@ export default defineNuxtPlugin<{ route: Route, router: Router }>({
if (import.meta.client || !nuxtApp.ssrContext?.islandContext) { if (import.meta.client || !nuxtApp.ssrContext?.islandContext) {
const middlewareEntries = new Set<RouteGuard>([...globalMiddleware, ...nuxtApp._middleware.global]) const middlewareEntries = new Set<RouteGuard>([...globalMiddleware, ...nuxtApp._middleware.global])
if (isAppManifestEnabled) {
const routeRules = await nuxtApp.runWithContext(() => getRouteRules(to.path))
if (routeRules.nuxtMiddleware) {
for (const key in routeRules.nuxtMiddleware) {
const guard = nuxtApp._middleware.named[key] as RouteGuard | undefined
if (!guard) { return }
if (routeRules.nuxtMiddleware[key]) {
middlewareEntries.add(guard)
} else {
middlewareEntries.delete(guard)
}
}
}
}
for (const middleware of middlewareEntries) { for (const middleware of middlewareEntries) {
const result = await nuxtApp.runWithContext(() => middleware(to, from)) const result = await nuxtApp.runWithContext(() => middleware(to, from))
if (import.meta.server) { if (import.meta.server) {

View File

@ -6,7 +6,7 @@ import { createRouter as createRadixRouter, exportMatcher, toRouteMatcher } from
import { randomUUID } from 'uncrypto' import { randomUUID } from 'uncrypto'
import { joinURL, withTrailingSlash } from 'ufo' import { joinURL, withTrailingSlash } from 'ufo'
import { build, copyPublicAssets, createDevServer, createNitro, prepare, prerender, scanHandlers, writeTypes } from 'nitropack' import { build, copyPublicAssets, createDevServer, createNitro, prepare, prerender, scanHandlers, writeTypes } from 'nitropack'
import type { Nitro, NitroConfig } from 'nitropack' import type { Nitro, NitroConfig, NitroOptions } from 'nitropack'
import { findPath, logger, resolveIgnorePatterns, resolveNuxtModule, resolvePath } from '@nuxt/kit' import { findPath, logger, resolveIgnorePatterns, resolveNuxtModule, resolvePath } from '@nuxt/kit'
import escapeRE from 'escape-string-regexp' import escapeRE from 'escape-string-regexp'
import { defu } from 'defu' import { defu } from 'defu'
@ -262,6 +262,25 @@ export async function initNitro (nuxt: Nuxt & { _nitro?: Nitro }) {
} }
) )
nuxt.options.alias['#app-manifest'] = join(tempDir, `meta/${buildId}.json`)
nuxt.hook('nitro:config', (config) => {
const rules = config.routeRules
for (const rule in rules) {
if (!(rules[rule] as any).nuxtMiddleware) { continue }
const value = (rules[rule] as any).nuxtMiddleware
if (typeof value === 'string') {
(rules[rule] as NitroOptions['routeRules']).nuxtMiddleware = { [value]: true }
} else if (Array.isArray(value)) {
const normalizedRules: Record<string, boolean> = {}
for (const middleware of value) {
normalizedRules[middleware] = true
}
(rules[rule] as NitroOptions['routeRules']).nuxtMiddleware = normalizedRules
}
}
})
nuxt.hook('nitro:init', (nitro) => { nuxt.hook('nitro:init', (nitro) => {
nitro.hooks.hook('rollup:before', async (nitro) => { nitro.hooks.hook('rollup:before', async (nitro) => {
const routeRules = {} as Record<string, any> const routeRules = {} as Record<string, any>
@ -272,8 +291,12 @@ export async function initNitro (nuxt: Nuxt & { _nitro?: Nitro }) {
const filteredRules = {} as Record<string, any> const filteredRules = {} as Record<string, any>
for (const routeKey in _routeRules[key]) { for (const routeKey in _routeRules[key]) {
const value = (_routeRules as any)[key][routeKey] const value = (_routeRules as any)[key][routeKey]
if (['prerender', 'redirect'].includes(routeKey) && value) { if (['prerender', 'redirect', 'nuxtMiddleware'].includes(routeKey) && value) {
filteredRules[routeKey] = routeKey === 'redirect' ? typeof value === 'string' ? value : value.to : value if (routeKey === 'redirect') {
filteredRules[routeKey] = typeof value === 'string' ? value : value.to
} else {
filteredRules[routeKey] = value
}
hasRules = true hasRules = true
} }
} }

View File

@ -244,6 +244,7 @@ declare module 'nitropack' {
interface NitroRouteRules { interface NitroRouteRules {
ssr?: boolean ssr?: boolean
experimentalNoScripts?: boolean experimentalNoScripts?: boolean
nuxtMiddleware?: Record<string, boolean>
} }
interface NitroRuntimeHooks { interface NitroRuntimeHooks {
'dev:ssr-logs': (ctx: { logs: LogObject[], path: string }) => void | Promise<void> 'dev:ssr-logs': (ctx: { logs: LogObject[], path: string }) => void | Promise<void>

View File

@ -474,6 +474,11 @@ export default defineNuxtModule({
' interface PageMeta {', ' interface PageMeta {',
' middleware?: MiddlewareKey | NavigationGuard | Array<MiddlewareKey | NavigationGuard>', ' middleware?: MiddlewareKey | NavigationGuard | Array<MiddlewareKey | NavigationGuard>',
' }', ' }',
'}',
'declare module \'nitropack\' {',
' interface NitroRouteConfig {',
' nuxtMiddleware?: MiddlewareKey | MiddlewareKey[] | Record<MiddlewareKey, boolean>',
' }',
'}' '}'
].join('\n') ].join('\n')
} }

View File

@ -15,10 +15,13 @@ import type { PageMeta } from '../composables'
import { toArray } from '../utils' import { toArray } from '../utils'
import type { Plugin, RouteMiddleware } from '#app' import type { Plugin, RouteMiddleware } from '#app'
import { getRouteRules } from '#app/composables/manifest'
import { defineNuxtPlugin, useRuntimeConfig } from '#app/nuxt' import { defineNuxtPlugin, useRuntimeConfig } from '#app/nuxt'
import { clearError, showError, useError } from '#app/composables/error' import { clearError, showError, useError } from '#app/composables/error'
import { navigateTo } from '#app/composables/router' import { navigateTo } from '#app/composables/router'
// @ts-expect-error virtual file
import { appManifest as isAppManifestEnabled } from '#build/nuxt.config.mjs'
// @ts-expect-error virtual file // @ts-expect-error virtual file
import _routes from '#build/routes' import _routes from '#build/routes'
// @ts-expect-error virtual file // @ts-expect-error virtual file
@ -173,6 +176,20 @@ const plugin: Plugin<{ router: Router }> = defineNuxtPlugin({
} }
} }
if (isAppManifestEnabled) {
const routeRules = await nuxtApp.runWithContext(() => getRouteRules(to.path))
if (routeRules.nuxtMiddleware) {
for (const key in routeRules.nuxtMiddleware) {
if (routeRules.nuxtMiddleware[key]) {
middlewareEntries.add(key)
} else {
middlewareEntries.delete(key)
}
}
}
}
for (const entry of middlewareEntries) { for (const entry of middlewareEntries) {
const middleware = typeof entry === 'string' ? nuxtApp._middleware.named[entry] || await namedMiddleware[entry]?.().then((r: any) => r.default || r) : entry const middleware = typeof entry === 'string' ? nuxtApp._middleware.named[entry] || await namedMiddleware[entry]?.().then((r: any) => r.default || r) : entry

View File

@ -26,6 +26,7 @@ declare module 'nitropack' {
interface NitroRouteRules { interface NitroRouteRules {
ssr?: boolean ssr?: boolean
experimentalNoScripts?: boolean experimentalNoScripts?: boolean
nuxtMiddleware?: Record<string, boolean>
} }
interface NitroRuntimeHooks { interface NitroRuntimeHooks {
'dev:ssr-logs': (ctx: { logs: LogObject[], path: string }) => void | Promise<void> 'dev:ssr-logs': (ctx: { logs: LogObject[], path: string }) => void | Promise<void>

View File

@ -26,6 +26,7 @@ declare module 'nitropack' {
interface NitroRouteRules { interface NitroRouteRules {
ssr?: boolean ssr?: boolean
experimentalNoScripts?: boolean experimentalNoScripts?: boolean
nuxtMiddleware?: Record<string, boolean>
} }
interface NitroRuntimeHooks { interface NitroRuntimeHooks {
'dev:ssr-logs': (ctx: { logs: LogObject[], path: string }) => void | Promise<void> 'dev:ssr-logs': (ctx: { logs: LogObject[], path: string }) => void | Promise<void>

View File

@ -80,6 +80,11 @@ describe('route rules', () => {
const html = await $fetch('/no-scripts') const html = await $fetch('/no-scripts')
expect(html).not.toContain('<script') expect(html).not.toContain('<script')
}) })
it.runIf(isTestingAppManifest)('should run middleware defined in routeRules config', async () => {
const html = await $fetch('/route-rules/middleware')
expect(html).toContain('Hello from routeRules!')
})
}) })
describe('modules', () => { describe('modules', () => {

View File

@ -19,7 +19,7 @@ describe.skipIf(process.env.SKIP_BUNDLE_SIZE === 'true' || process.env.ECOSYSTEM
for (const outputDir of ['.output', '.output-inline']) { for (const outputDir of ['.output', '.output-inline']) {
it('default client bundle size', async () => { it('default client bundle size', async () => {
const clientStats = await analyzeSizes('**/*.js', join(rootDir, outputDir, 'public')) const clientStats = await analyzeSizes('**/*.js', join(rootDir, outputDir, 'public'))
expect.soft(roundToKilobytes(clientStats.totalBytes)).toMatchInlineSnapshot('"105k"') expect.soft(roundToKilobytes(clientStats.totalBytes)).toMatchInlineSnapshot('"106k"')
expect(clientStats.files.map(f => f.replace(/\..*\.js/, '.js'))).toMatchInlineSnapshot(` expect(clientStats.files.map(f => f.replace(/\..*\.js/, '.js'))).toMatchInlineSnapshot(`
[ [
"_nuxt/entry.js", "_nuxt/entry.js",
@ -32,7 +32,7 @@ describe.skipIf(process.env.SKIP_BUNDLE_SIZE === 'true' || process.env.ECOSYSTEM
const serverDir = join(rootDir, '.output/server') const serverDir = join(rootDir, '.output/server')
const serverStats = await analyzeSizes(['**/*.mjs', '!node_modules'], serverDir) const serverStats = await analyzeSizes(['**/*.mjs', '!node_modules'], serverDir)
expect.soft(roundToKilobytes(serverStats.totalBytes)).toMatchInlineSnapshot('"205k"') expect.soft(roundToKilobytes(serverStats.totalBytes)).toMatchInlineSnapshot('"206k"')
const modules = await analyzeSizes('node_modules/**/*', serverDir) const modules = await analyzeSizes('node_modules/**/*', serverDir)
expect.soft(roundToKilobytes(modules.totalBytes)).toMatchInlineSnapshot('"1336k"') expect.soft(roundToKilobytes(modules.totalBytes)).toMatchInlineSnapshot('"1336k"')
@ -72,7 +72,7 @@ describe.skipIf(process.env.SKIP_BUNDLE_SIZE === 'true' || process.env.ECOSYSTEM
const serverDir = join(rootDir, '.output-inline/server') const serverDir = join(rootDir, '.output-inline/server')
const serverStats = await analyzeSizes(['**/*.mjs', '!node_modules'], serverDir) const serverStats = await analyzeSizes(['**/*.mjs', '!node_modules'], serverDir)
expect.soft(roundToKilobytes(serverStats.totalBytes)).toMatchInlineSnapshot('"524k"') expect.soft(roundToKilobytes(serverStats.totalBytes)).toMatchInlineSnapshot('"525k"')
const modules = await analyzeSizes('node_modules/**/*', serverDir) const modules = await analyzeSizes('node_modules/**/*', serverDir)
expect.soft(roundToKilobytes(modules.totalBytes)).toMatchInlineSnapshot('"77.8k"') expect.soft(roundToKilobytes(modules.totalBytes)).toMatchInlineSnapshot('"77.8k"')

View File

@ -0,0 +1,3 @@
export default defineNuxtRouteMiddleware((to) => {
to.meta.hello = 'Hello from routeRules!'
})

View File

@ -60,6 +60,7 @@ export default defineNuxtConfig({
}, },
routeRules: { routeRules: {
'/route-rules/spa': { ssr: false }, '/route-rules/spa': { ssr: false },
'/route-rules/middleware': { nuxtMiddleware: 'route-rules-middleware' },
'/hydration/spa-redirection/**': { ssr: false }, '/hydration/spa-redirection/**': { ssr: false },
'/no-scripts': { experimentalNoScripts: true } '/no-scripts': { experimentalNoScripts: true }
}, },

View File

@ -0,0 +1,5 @@
<template>
<div>
<div>Greeting: {{ $route.meta.hello }}</div>
</div>
</template>

View File

@ -19,29 +19,6 @@ import { useId } from '#app/composables/id'
import { callOnce } from '#app/composables/once' import { callOnce } from '#app/composables/once'
import { useLoadingIndicator } from '#app/composables/loading-indicator' import { useLoadingIndicator } from '#app/composables/loading-indicator'
vi.mock('#app/compat/idle-callback', () => ({
requestIdleCallback: (cb: Function) => cb()
}))
const timestamp = Date.now()
registerEndpoint('/_nuxt/builds/latest.json', defineEventHandler(() => ({
id: 'override',
timestamp
})))
registerEndpoint('/_nuxt/builds/meta/override.json', defineEventHandler(() => ({
id: 'override',
timestamp,
matcher: {
static: {
'/': null,
'/pre': null,
'/pre/test': { redirect: true }
},
wildcard: { '/pre': { prerender: true } },
dynamic: {}
},
prerendered: ['/specific-prerendered']
})))
registerEndpoint('/api/test', defineEventHandler(event => ({ registerEndpoint('/api/test', defineEventHandler(event => ({
method: event.method, method: event.method,
headers: Object.fromEntries(event.headers.entries()) headers: Object.fromEntries(event.headers.entries())

28
test/setup-runtime.ts Normal file
View File

@ -0,0 +1,28 @@
import { vi } from 'vitest'
import { defineEventHandler } from 'h3'
import { registerEndpoint } from '@nuxt/test-utils/runtime'
vi.mock('#app/compat/idle-callback', () => ({
requestIdleCallback: (cb: Function) => cb()
}))
const timestamp = Date.now()
registerEndpoint('/_nuxt/builds/latest.json', defineEventHandler(() => ({
id: 'override',
timestamp
})))
registerEndpoint('/_nuxt/builds/meta/override.json', defineEventHandler(() => ({
id: 'override',
timestamp,
matcher: {
static: {
'/': null,
'/pre': null,
'/pre/test': { redirect: true }
},
wildcard: { '/pre': { prerender: true } },
dynamic: {}
},
prerendered: ['/specific-prerendered']
})))

View File

@ -7,6 +7,9 @@ export default defineVitestConfig({
include: ['packages/nuxt/src/app'] include: ['packages/nuxt/src/app']
}, },
environment: 'nuxt', environment: 'nuxt',
setupFiles: [
'./test/setup-runtime.ts'
],
environmentOptions: { environmentOptions: {
nuxt: { nuxt: {
overrides: { overrides: {