feat(nuxt): add support for routeRules defined within pages (#20391)

This commit is contained in:
Daniel Roe 2023-08-23 21:38:17 +01:00 committed by GitHub
parent 427e64d175
commit 29f4eeff69
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 230 additions and 14 deletions

View File

@ -9,11 +9,14 @@ import { createRoutesContext } from 'unplugin-vue-router'
import { resolveOptions } from 'unplugin-vue-router/options' import { resolveOptions } from 'unplugin-vue-router/options'
import type { EditableTreeNode, Options as TypedRouterOptions } from 'unplugin-vue-router' import type { EditableTreeNode, Options as TypedRouterOptions } from 'unplugin-vue-router'
import type { NitroRouteConfig } from 'nitropack'
import { defu } from 'defu'
import { distDir } from '../dirs' import { distDir } from '../dirs'
import { normalizeRoutes, resolvePagesRoutes } from './utils' import { normalizeRoutes, resolvePagesRoutes } from './utils'
import type { PageMetaPluginOptions } from './page-meta' import { extractRouteRules, getMappedPages } from './route-rules'
import { PageMetaPlugin } from './page-meta' import type { PageMetaPluginOptions } from './plugins/page-meta'
import { RouteInjectionPlugin } from './route-injection' import { PageMetaPlugin } from './plugins/page-meta'
import { RouteInjectionPlugin } from './plugins/route-injection'
const OPTIONAL_PARAM_RE = /^\/?:.*(\?|\(\.\*\)\*)$/ const OPTIONAL_PARAM_RE = /^\/?:.*(\?|\(\.\*\)\*)$/
@ -239,8 +242,69 @@ export default defineNuxtModule({
{ name: 'definePageMeta', as: 'definePageMeta', from: resolve(runtimeDir, 'composables') }, { name: 'definePageMeta', as: 'definePageMeta', from: resolve(runtimeDir, 'composables') },
{ name: 'useLink', as: 'useLink', from: '#vue-router' } { name: 'useLink', as: 'useLink', from: '#vue-router' }
) )
if (nuxt.options.experimental.inlineRouteRules) {
imports.push({ name: 'defineRouteRules', as: 'defineRouteRules', from: resolve(runtimeDir, 'composables') })
}
}) })
if (nuxt.options.experimental.inlineRouteRules) {
// Track mappings of absolute files to globs
let pageToGlobMap = {} as { [absolutePath: string]: string | null }
nuxt.hook('pages:extend', (pages) => { pageToGlobMap = getMappedPages(pages) })
// Extracted route rules defined inline in pages
const inlineRules = {} as { [glob: string]: NitroRouteConfig }
// Allow telling Nitro to reload route rules
let updateRouteConfig: () => void | Promise<void>
nuxt.hook('nitro:init', (nitro) => {
updateRouteConfig = () => nitro.updateConfig({ routeRules: defu(inlineRules, nitro.options._config.routeRules) })
})
async function updatePage (path: string) {
const glob = pageToGlobMap[path]
const code = path in nuxt.vfs ? nuxt.vfs[path] : await readFile(path!, 'utf-8')
try {
const extractedRule = await extractRouteRules(code)
if (extractedRule) {
if (!glob) {
const relativePath = relative(nuxt.options.srcDir, path)
console.error(`[nuxt] Could not set inline route rules in \`~/${relativePath}\` as it could not be mapped to a Nitro route.`)
return
}
inlineRules[glob] = extractedRule
} else if (glob) {
delete inlineRules[glob]
}
} catch (e: any) {
if (e.toString().includes('Error parsing route rules')) {
const relativePath = relative(nuxt.options.srcDir, path)
console.error(`[nuxt] Error parsing route rules within \`~/${relativePath}\`. They should be JSON-serializable.`)
} else {
console.error(e)
}
}
}
nuxt.hook('builder:watch', async (event, relativePath) => {
const path = join(nuxt.options.srcDir, relativePath)
if (!(path in pageToGlobMap)) { return }
if (event === 'unlink') {
delete inlineRules[path]
delete pageToGlobMap[path]
} else {
await updatePage(path)
}
await updateRouteConfig?.()
})
nuxt.hooks.hookOnce('pages:extend', async () => {
for (const page in pageToGlobMap) { await updatePage(page) }
await updateRouteConfig?.()
})
}
// Extract macros from pages // Extract macros from pages
const pageMetaOptions: PageMetaPluginOptions = { const pageMetaOptions: PageMetaPluginOptions = {
dev: nuxt.options.dev, dev: nuxt.options.dev,

View File

@ -1,7 +1,7 @@
import { createUnplugin } from 'unplugin' import { createUnplugin } from 'unplugin'
import MagicString from 'magic-string' import MagicString from 'magic-string'
import type { Nuxt } from '@nuxt/schema' import type { Nuxt } from '@nuxt/schema'
import { isVue } from '../core/utils' import { isVue } from '../../core/utils'
const INJECTION_RE = /\b_ctx\.\$route\b/g const INJECTION_RE = /\b_ctx\.\$route\b/g
const INJECTION_SINGLE_RE = /\b_ctx\.\$route\b/ const INJECTION_SINGLE_RE = /\b_ctx\.\$route\b/

View File

@ -0,0 +1,60 @@
import { runInNewContext } from 'node:vm'
import type { Node } from 'estree-walker'
import type { CallExpression } from 'estree'
import { walk } from 'estree-walker'
import { transform } from 'esbuild'
import { parse } from 'acorn'
import type { NuxtPage } from '@nuxt/schema'
import type { NitroRouteConfig } from 'nitropack'
import { normalize } from 'pathe'
import { extractScriptContent, pathToNitroGlob } from './utils'
const ROUTE_RULE_RE = /\bdefineRouteRules\(/
const ruleCache: Record<string, NitroRouteConfig | null> = {}
export async function extractRouteRules (code: string): Promise<NitroRouteConfig | null> {
if (code in ruleCache) {
return ruleCache[code]
}
if (!ROUTE_RULE_RE.test(code)) { return null }
code = extractScriptContent(code) || code
let rule: NitroRouteConfig | null = null
const js = await transform(code, { loader: 'ts' })
walk(parse(js.code, {
sourceType: 'module',
ecmaVersion: 'latest'
}) 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 === 'defineRouteRules') {
const rulesString = js.code.slice(node.start, node.end)
try {
rule = JSON.parse(runInNewContext(rulesString.replace('defineRouteRules', 'JSON.stringify'), {}))
} catch {
throw new Error('[nuxt] Error parsing route rules. They should be JSON-serializable.')
}
}
}
})
ruleCache[code] = rule
return rule
}
export function getMappedPages (pages: NuxtPage[], paths = {} as { [absolutePath: string]: string | null }, prefix = '') {
for (const page of pages) {
if (page.file) {
const filename = normalize(page.file)
paths[filename] = pathToNitroGlob(prefix + page.path)
}
if (page.children) {
getMappedPages(page.children, paths, page.path + '/')
}
}
return paths
}

View File

@ -2,6 +2,7 @@ import type { KeepAliveProps, TransitionProps, UnwrapRef } from 'vue'
import { getCurrentInstance } from 'vue' import { getCurrentInstance } from 'vue'
import type { RouteLocationNormalized, RouteLocationNormalizedLoaded, RouteRecordRedirectOption } from '#vue-router' import type { RouteLocationNormalized, RouteLocationNormalizedLoaded, RouteRecordRedirectOption } from '#vue-router'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import type { NitroRouteConfig } from 'nitropack'
import type { NuxtError } from '#app' import type { NuxtError } from '#app'
export interface PageMeta { export interface PageMeta {
@ -64,3 +65,15 @@ export const definePageMeta = (meta: PageMeta): void => {
warnRuntimeUsage('definePageMeta') warnRuntimeUsage('definePageMeta')
} }
} }
/**
* You can define route rules for the current page. Matching route rules will be created, based on the page's _path_.
*
* For example, a rule defined in `~/pages/foo/bar.vue` will be applied to `/foo/bar` requests. A rule in
* `~/pages/foo/[id].vue` will be applied to `/foo/**` requests.
*
* For more control, such as if you are using a custom `path` or `alias` set in the page's `definePageMeta`, you
* should set `routeRules` directly within your `nuxt.config`.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export const defineRouteRules = (rules: NitroRouteConfig): void => {}

View File

@ -107,7 +107,7 @@ export async function generateRoutesFromFiles (files: string[], pagesDir: string
} }
const SFC_SCRIPT_RE = /<script\s*[^>]*>([\s\S]*?)<\/script\s*[^>]*>/i const SFC_SCRIPT_RE = /<script\s*[^>]*>([\s\S]*?)<\/script\s*[^>]*>/i
function extractScriptContent (html: string) { export function extractScriptContent (html: string) {
const match = html.match(SFC_SCRIPT_RE) const match = html.match(SFC_SCRIPT_RE)
if (match && match[1]) { if (match && match[1]) {
@ -335,3 +335,15 @@ export function normalizeRoutes (routes: NuxtPage[], metaImports: Set<string> =
})) }))
} }
} }
export function pathToNitroGlob (path: string) {
if (!path) {
return null
}
// Ignore pages with multiple dynamic parameters.
if (path.indexOf(':') !== path.lastIndexOf(':')) {
return null
}
return path.replace(/\/(?:[^:/]+)?:\w+.*$/, '/**')
}

View File

@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import type { NuxtPage } from 'nuxt/schema' import type { NuxtPage } from 'nuxt/schema'
import { generateRoutesFromFiles } from '../src/pages/utils' import { generateRoutesFromFiles, pathToNitroGlob } from '../src/pages/utils'
import { generateRouteKey } from '../src/pages/runtime/utils' import { generateRouteKey } from '../src/pages/runtime/utils'
describe('pages:generateRoutesFromFiles', () => { describe('pages:generateRoutesFromFiles', () => {
@ -442,3 +442,20 @@ describe('pages:generateRouteKey', () => {
}) })
} }
}) })
const pathToNitroGlobTests = {
'/': '/',
'/:id': '/**',
'/:id()': '/**',
'/:id?': '/**',
'/some-:id?': '/**',
'/other/some-:id?': '/other/**',
'/other/some-:id()-more': '/other/**',
'/other/nested': '/other/nested'
}
describe('pages:pathToNitroGlob', () => {
it.each(Object.entries(pathToNitroGlobTests))('should convert %s to %s', (path, expected) => {
expect(pathToNitroGlob(path)).to.equal(expected)
})
})

View File

@ -238,6 +238,18 @@ export default defineUntypedSchema({
* *
* @see https://github.com/nuxt/nuxt/discussions/22632 * @see https://github.com/nuxt/nuxt/discussions/22632
*/ */
headNext: false headNext: false,
/**
* Allow defining `routeRules` directly within your `~/pages` directory using `defineRouteRules`.
*
* Rules are converted (based on the path) and applied for server requests. For example, a rule
* defined in `~/pages/foo/bar.vue` will be applied to `/foo/bar` requests. A rule in `~/pages/foo/[id].vue`
* will be applied to `/foo/**` requests.
*
* For more control, such as if you are using a custom `path` or `alias` set in the page's `definePageMeta`, you
* should set `routeRules` directly within your `nuxt.config`.
*/
inlineRouteRules: false
} }
}) })

View File

@ -83,7 +83,7 @@ export async function buildClient (ctx: ViteBuildContext) {
viteNodePlugin(ctx), viteNodePlugin(ctx),
pureAnnotationsPlugin.vite({ pureAnnotationsPlugin.vite({
sourcemap: ctx.nuxt.options.sourcemap.client, sourcemap: ctx.nuxt.options.sourcemap.client,
functions: ['defineComponent', 'defineAsyncComponent', 'defineNuxtLink', 'createClientOnly', 'defineNuxtPlugin', 'defineNuxtRouteMiddleware', 'defineNuxtComponent', 'useRuntimeConfig'] functions: ['defineComponent', 'defineAsyncComponent', 'defineNuxtLink', 'createClientOnly', 'defineNuxtPlugin', 'defineNuxtRouteMiddleware', 'defineNuxtComponent', 'useRuntimeConfig', 'defineRouteRules']
}) })
], ],
appType: 'custom', appType: 'custom',

View File

@ -107,7 +107,7 @@ export async function buildServer (ctx: ViteBuildContext) {
plugins: [ plugins: [
pureAnnotationsPlugin.vite({ pureAnnotationsPlugin.vite({
sourcemap: ctx.nuxt.options.sourcemap.server, sourcemap: ctx.nuxt.options.sourcemap.server,
functions: ['defineComponent', 'defineAsyncComponent', 'defineNuxtLink', 'createClientOnly', 'defineNuxtPlugin', 'defineNuxtRouteMiddleware', 'defineNuxtComponent', 'useRuntimeConfig'] functions: ['defineComponent', 'defineAsyncComponent', 'defineNuxtLink', 'createClientOnly', 'defineNuxtPlugin', 'defineNuxtRouteMiddleware', 'defineNuxtComponent', 'useRuntimeConfig', 'defineRouteRules']
}) })
] ]
} satisfies vite.InlineConfig, ctx.nuxt.options.vite.$server || {})) } satisfies vite.InlineConfig, ctx.nuxt.options.vite.$server || {}))

View File

@ -52,6 +52,12 @@ describe('route rules', () => {
await expectNoClientErrors('/route-rules/spa') await expectNoClientErrors('/route-rules/spa')
}) })
it('should allow defining route rules inline', async () => {
const res = await fetch('/route-rules/inline')
expect(res.status).toEqual(200)
expect(res.headers.get('x-extend')).toEqual('added in routeRules')
})
it('test noScript routeRules', async () => { it('test noScript routeRules', async () => {
const html = await $fetch('/no-scripts') const html = await $fetch('/no-scripts')
expect(html).not.toContain('<script') expect(html).not.toContain('<script')

View File

@ -195,7 +195,8 @@ export default defineNuxtConfig({
treeshakeClientOnly: true, treeshakeClientOnly: true,
payloadExtraction: true, payloadExtraction: true,
asyncContext: process.env.TEST_CONTEXT === 'async', asyncContext: process.env.TEST_CONTEXT === 'async',
headNext: true headNext: true,
inlineRouteRules: true
}, },
appConfig: { appConfig: {
fromNuxtConfig: true, fromNuxtConfig: true,

View File

@ -0,0 +1,13 @@
<script setup lang="ts">
defineRouteRules({
headers: {
'x-extend': 'added in routeRules'
}
})
</script>
<template>
<div>
Route rules defined inline
</div>
</template>

View File

@ -3,7 +3,7 @@ import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { isWindows } from 'std-env' import { isWindows } from 'std-env'
import { join } from 'pathe' import { join } from 'pathe'
import { $fetch, setup } from '@nuxt/test-utils' import { $fetch, fetch, setup } from '@nuxt/test-utils'
import { expectWithPolling, renderPage } from './utils' import { expectWithPolling, renderPage } from './utils'
@ -70,15 +70,33 @@ if (process.env.TEST_ENV !== 'built' && !isWindows) {
}, 60_000) }, 60_000)
it('should detect new routes', async () => { it('should detect new routes', async () => {
const html = await $fetch('/some-404') await expectWithPolling(
expect(html).toContain('catchall at some-404') () => $fetch('/some-404').then(r => r.includes('catchall at some-404')).catch(() => null),
true
)
// write new page route // write new page route
const indexVue = await fsp.readFile(join(fixturePath, 'pages/index.vue'), 'utf8') const indexVue = await fsp.readFile(join(fixturePath, 'pages/index.vue'), 'utf8')
await fsp.writeFile(join(fixturePath, 'pages/some-404.vue'), indexVue) await fsp.writeFile(join(fixturePath, 'pages/some-404.vue'), indexVue)
await expectWithPolling( await expectWithPolling(
() => $fetch('/some-404').then(r => r.includes('Hello Nuxt 3')), () => $fetch('/some-404').then(r => r.includes('Hello Nuxt 3')).catch(() => null),
true
)
})
it('should hot reload route rules', async () => {
await expectWithPolling(
() => fetch('/route-rules/inline').then(r => r.headers.get('x-extend') === 'added in routeRules').catch(() => null),
true
)
// write new page route
const file = await fsp.readFile(join(fixturePath, 'pages/route-rules/inline.vue'), 'utf8')
await fsp.writeFile(join(fixturePath, 'pages/route-rules/inline.vue'), file.replace('added in routeRules', 'edited in dev'))
await expectWithPolling(
() => fetch('/route-rules/inline').then(r => r.headers.get('x-extend') === 'edited in dev').catch(() => null),
true true
) )
}) })