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

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

Merged
merged 20 commits into from Aug 23, 2023
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
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
69 changes: 66 additions & 3 deletions packages/nuxt/src/pages/module.ts
Expand Up @@ -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 = /^\/?:.*(\?|\(\.\*\)\*)$/

Expand Down Expand Up @@ -237,10 +240,70 @@ export default defineNuxtModule({
nuxt.hook('imports:extend', (imports) => {
imports.push(
{ name: 'definePageMeta', as: 'definePageMeta', from: resolve(runtimeDir, 'composables') },
{ name: 'defineRouteRules', as: 'defineRouteRules', from: resolve(runtimeDir, 'composables') },
{ name: 'useLink', as: 'useLink', from: '#vue-router' }
)
})

// 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 }
// User-provided route rules from `nuxt.config`
let baseRules: { [glob: string]: NitroRouteConfig } | undefined
nuxt.hook('nitro:config', (config) => { baseRules = config.routeRules })

// Allow telling Nitro to reload route rules
let updateRouteConfig: () => void | Promise<void>
nuxt.hook('nitro:init', (nitro) => {
updateRouteConfig = () => nitro.updateConfig({ routeRules: defu(inlineRules, baseRules) })
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One concern would be if other modules are also updating the route rules at runtime, it seems like they would get overridden?

I think the scope for this happening, especially just in development, is pretty low.

Maybe worth merging in nitro.options.routeRules though?

Copy link
Member Author

@danielroe danielroe Aug 22, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can use nitro.options._config.routeRules to access the un-normalised rules.

cc: @pi0 - thoughts on how to avoid clobbering pre-existing route rules?

Copy link
Member

@pi0 pi0 Aug 22, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess merging with nuxt.options.routeRules would be safer as it contains the latest user (nuxt module) modified rules. In context of Nuxt with top level routeRules, it should be main source of trust.

(we sync runtimeConfig from nuxt to nitro. might need to do a similar fix for route rules as well to make this possible)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitro:config hook is last place for modules to modify nitro config, so that might be safer. (Hence current and previous implementations.)

Otherwise when we update the rules we overwrite intervening changes.

Or maybe use proxy for runtimeConfig/routeRules in resolved options that automatically reloads both within nitro?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Modules might need to also apply route rules afterward as well (similar to runtimeConfig). We can either sync references so that nitro and nuxt modules update same object or proxy is also good idea for auto reload πŸ‘πŸΌ (a possible downside is that sometimes we need to batch changes, advising to prefer update config hook is safer)

Copy link
Member Author

@danielroe danielroe Aug 22, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about instead letting nitro keep track of current state and only merge in/reload the updated rules? That way we wouldn't overwrite anything or need to implement proxy/etc within Nuxt...

})

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,
Expand Down
@@ -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/
Expand Down
60 changes: 60 additions & 0 deletions 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<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
}
10 changes: 10 additions & 0 deletions packages/nuxt/src/pages/runtime/composables.ts
Expand Up @@ -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 {
Expand Down Expand Up @@ -64,3 +65,12 @@ 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 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 => {}
14 changes: 13 additions & 1 deletion packages/nuxt/src/pages/utils.ts
Expand Up @@ -107,7 +107,7 @@ export async function generateRoutesFromFiles (files: string[], pagesDir: string
}

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)

if (match && match[1]) {
Expand Down Expand Up @@ -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+.*$/, '/**')
}
19 changes: 18 additions & 1 deletion 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', () => {
Expand Down Expand Up @@ -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)
})
})
2 changes: 1 addition & 1 deletion packages/vite/src/client.ts
Expand Up @@ -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',
Expand Down
2 changes: 1 addition & 1 deletion packages/vite/src/server.ts
Expand Up @@ -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 || {}))
Expand Down
6 changes: 6 additions & 0 deletions test/basic.test.ts
Expand Up @@ -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('<script')
Expand Down
13 changes: 13 additions & 0 deletions test/fixtures/basic/pages/route-rules/inline.vue
@@ -0,0 +1,13 @@
<script setup lang="ts">
defineRouteRules({
headers: {
'x-extend': 'added in routeRules'
}
})
</script>

<template>
<div>
Route rules defined inline
</div>
</template>
22 changes: 19 additions & 3 deletions test/hmr.test.ts
Expand Up @@ -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'

Expand Down Expand Up @@ -70,8 +70,10 @@ 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')
Expand All @@ -82,6 +84,20 @@ if (process.env.TEST_ENV !== 'built' && !isWindows) {
true
)
})

it('should hot reload route rules', async () => {
const res = await fetch('/route-rules/inline')
expect(res.headers.get('x-extend')).toBe('added in routeRules')

// 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'),
true
)
})
})
} else {
describe.skip('hmr', () => {})
Expand Down