diff --git a/packages/nuxt/src/pages/module.ts b/packages/nuxt/src/pages/module.ts index 57d6f9237002..192d36fd98a0 100644 --- a/packages/nuxt/src/pages/module.ts +++ b/packages/nuxt/src/pages/module.ts @@ -9,11 +9,14 @@ import { createRoutesContext } from 'unplugin-vue-router' import { resolveOptions } from 'unplugin-vue-router/options' import type { EditableTreeNode, Options as TypedRouterOptions } from 'unplugin-vue-router' +import type { NitroRouteConfig } from 'nitropack' +import { defu } from 'defu' import { distDir } from '../dirs' import { normalizeRoutes, resolvePagesRoutes } from './utils' -import type { PageMetaPluginOptions } from './page-meta' -import { PageMetaPlugin } from './page-meta' -import { RouteInjectionPlugin } from './route-injection' +import { extractRouteRules, getMappedPages } from './route-rules' +import type { PageMetaPluginOptions } from './plugins/page-meta' +import { PageMetaPlugin } from './plugins/page-meta' +import { RouteInjectionPlugin } from './plugins/route-injection' const OPTIONAL_PARAM_RE = /^\/?:.*(\?|\(\.\*\)\*)$/ @@ -239,8 +242,69 @@ export default defineNuxtModule({ { name: 'definePageMeta', as: 'definePageMeta', from: resolve(runtimeDir, 'composables') }, { 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 + 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 const pageMetaOptions: PageMetaPluginOptions = { dev: nuxt.options.dev, diff --git a/packages/nuxt/src/pages/page-meta.ts b/packages/nuxt/src/pages/plugins/page-meta.ts similarity index 100% rename from packages/nuxt/src/pages/page-meta.ts rename to packages/nuxt/src/pages/plugins/page-meta.ts diff --git a/packages/nuxt/src/pages/route-injection.ts b/packages/nuxt/src/pages/plugins/route-injection.ts similarity index 96% rename from packages/nuxt/src/pages/route-injection.ts rename to packages/nuxt/src/pages/plugins/route-injection.ts index 9e32f1bd6d14..0a89d744701a 100644 --- a/packages/nuxt/src/pages/route-injection.ts +++ b/packages/nuxt/src/pages/plugins/route-injection.ts @@ -1,7 +1,7 @@ import { createUnplugin } from 'unplugin' import MagicString from 'magic-string' 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_SINGLE_RE = /\b_ctx\.\$route\b/ diff --git a/packages/nuxt/src/pages/route-rules.ts b/packages/nuxt/src/pages/route-rules.ts new file mode 100644 index 000000000000..c931c8754600 --- /dev/null +++ b/packages/nuxt/src/pages/route-rules.ts @@ -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 = {} + +export async function extractRouteRules (code: string): Promise { + 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 +} diff --git a/packages/nuxt/src/pages/runtime/composables.ts b/packages/nuxt/src/pages/runtime/composables.ts index af3af4bfdbd9..0f189e578b17 100644 --- a/packages/nuxt/src/pages/runtime/composables.ts +++ b/packages/nuxt/src/pages/runtime/composables.ts @@ -2,6 +2,7 @@ import type { KeepAliveProps, TransitionProps, UnwrapRef } from 'vue' import { getCurrentInstance } from 'vue' import type { RouteLocationNormalized, RouteLocationNormalizedLoaded, RouteRecordRedirectOption } from '#vue-router' import { useRoute } from 'vue-router' +import type { NitroRouteConfig } from 'nitropack' import type { NuxtError } from '#app' export interface PageMeta { @@ -64,3 +65,15 @@ export const definePageMeta = (meta: PageMeta): void => { 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 => {} diff --git a/packages/nuxt/src/pages/utils.ts b/packages/nuxt/src/pages/utils.ts index aefaa7b0b564..7e20e617b3b5 100644 --- a/packages/nuxt/src/pages/utils.ts +++ b/packages/nuxt/src/pages/utils.ts @@ -107,7 +107,7 @@ export async function generateRoutesFromFiles (files: string[], pagesDir: string } const SFC_SCRIPT_RE = /]*>([\s\S]*?)<\/script\s*[^>]*>/i -function extractScriptContent (html: string) { +export function extractScriptContent (html: string) { const match = html.match(SFC_SCRIPT_RE) if (match && match[1]) { @@ -335,3 +335,15 @@ export function normalizeRoutes (routes: NuxtPage[], metaImports: Set = })) } } + +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+.*$/, '/**') +} diff --git a/packages/nuxt/test/pages.test.ts b/packages/nuxt/test/pages.test.ts index 0a98fc442518..8d21d9126438 100644 --- a/packages/nuxt/test/pages.test.ts +++ b/packages/nuxt/test/pages.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' 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' 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) + }) +}) diff --git a/packages/schema/src/config/experimental.ts b/packages/schema/src/config/experimental.ts index f0a261ecbf5d..66ef5d69ad7b 100644 --- a/packages/schema/src/config/experimental.ts +++ b/packages/schema/src/config/experimental.ts @@ -238,6 +238,18 @@ export default defineUntypedSchema({ * * @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 } }) diff --git a/packages/vite/src/client.ts b/packages/vite/src/client.ts index 0bd472de9eaa..207337553292 100644 --- a/packages/vite/src/client.ts +++ b/packages/vite/src/client.ts @@ -83,7 +83,7 @@ export async function buildClient (ctx: ViteBuildContext) { viteNodePlugin(ctx), pureAnnotationsPlugin.vite({ 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', diff --git a/packages/vite/src/server.ts b/packages/vite/src/server.ts index f5b2989aa976..c7bdbe71a698 100644 --- a/packages/vite/src/server.ts +++ b/packages/vite/src/server.ts @@ -107,7 +107,7 @@ export async function buildServer (ctx: ViteBuildContext) { plugins: [ pureAnnotationsPlugin.vite({ 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 || {})) diff --git a/test/basic.test.ts b/test/basic.test.ts index 2b97b919cdce..b6748654f5ea 100644 --- a/test/basic.test.ts +++ b/test/basic.test.ts @@ -52,6 +52,12 @@ describe('route rules', () => { 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 () => { const html = await $fetch('/no-scripts') expect(html).not.toContain(' +defineRouteRules({ + headers: { + 'x-extend': 'added in routeRules' + } +}) + + + diff --git a/test/hmr.test.ts b/test/hmr.test.ts index bf1d2410214c..1af0b08de7ed 100644 --- a/test/hmr.test.ts +++ b/test/hmr.test.ts @@ -3,7 +3,7 @@ import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' import { isWindows } from 'std-env' import { join } from 'pathe' -import { $fetch, setup } from '@nuxt/test-utils' +import { $fetch, fetch, setup } from '@nuxt/test-utils' import { expectWithPolling, renderPage } from './utils' @@ -70,15 +70,33 @@ if (process.env.TEST_ENV !== 'built' && !isWindows) { }, 60_000) it('should detect new routes', async () => { - const html = await $fetch('/some-404') - expect(html).toContain('catchall at some-404') + await expectWithPolling( + () => $fetch('/some-404').then(r => r.includes('catchall at some-404')).catch(() => null), + true + ) // write new page route const indexVue = await fsp.readFile(join(fixturePath, 'pages/index.vue'), 'utf8') await fsp.writeFile(join(fixturePath, 'pages/some-404.vue'), indexVue) 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 ) })