feat(nuxt3): add universal routing utilities (#3274)

This commit is contained in:
Daniel Roe 2022-02-21 13:03:42 +00:00 committed by GitHub
parent ed411c687d
commit dbab979a2e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 443 additions and 94 deletions

View File

@ -0,0 +1,35 @@
<script setup lang="ts">
const route = useRoute()
const timer = useState('timer', () => 0)
</script>
<template>
<NuxtExampleLayout example="with-universal-router">
A page...
<br>
<template v-if="timer">
Processing navigation in {{ timer }} seconds
</template>
<template #nav>
<nav class="flex align-center gap-4 p-4">
<NuxtLink to="/" class="n-link-base">
Home
</NuxtLink>
<NuxtLink to="/forbidden" class="n-link-base">
Forbidden
</NuxtLink>
<NuxtLink to="/redirect" class="n-link-base">
Redirect
</NuxtLink>
</nav>
</template>
<template #footer>
<div class="text-center p-4 op-50">
Current route: <code>{{ route.path }}</code>
</div>
</template>
</NuxtExampleLayout>
</template>

View File

@ -0,0 +1,7 @@
import { defineNuxtConfig } from 'nuxt3'
export default defineNuxtConfig({
modules: [
'@nuxt/ui'
]
})

View File

@ -0,0 +1,13 @@
{
"name": "example-with-universal-router",
"private": true,
"scripts": {
"build": "nuxi build",
"dev": "nuxi dev",
"start": "nuxi preview"
},
"devDependencies": {
"@nuxt/ui": "npm:@nuxt/ui-edge@latest",
"nuxt3": "latest"
}
}

View File

@ -0,0 +1,33 @@
export default defineNuxtPlugin(() => {
const timer = useState('timer', () => 0)
if (process.client) {
addRouteMiddleware(async () => {
console.log('Starting timer...')
timer.value = 5
do {
await new Promise(resolve => setTimeout(resolve, 100))
timer.value--
} while (timer.value)
console.log('...and navigating')
})
}
addRouteMiddleware((to) => {
if (to.path === '/forbidden') {
return false
}
})
addRouteMiddleware((to) => {
const { $config } = useNuxtApp()
if ($config) {
console.log('Accessed runtime config within middleware.')
}
if (to.path !== '/redirect') { return }
console.log('Heading to', to.path, 'but I think we should go somewhere else...')
return '/secret'
})
})

View File

@ -0,0 +1,3 @@
{
"extends": "./.nuxt/tsconfig.json"
}

View File

@ -27,14 +27,6 @@ export function setupAutoImports () {
}
}
// Add auto-imports that are added by ad-hoc modules in nuxt 3
autoImports.push({ name: 'useRouter', as: 'useRouter', from: '#app' })
autoImports.push({ name: 'useRoute', as: 'useRoute', from: '#app' })
autoImports.push({ name: 'addRouteMiddleware', as: 'addRouteMiddleware', from: '#app' })
autoImports.push({ name: 'navigateTo', as: 'navigateTo', from: '#app' })
autoImports.push({ name: 'abortNavigation', as: 'abortNavigation', from: '#app' })
autoImports.push({ name: 'defineNuxtRouteMiddleware', as: 'defineNuxtRouteMiddleware', from: '#app' })
// Add bridge-only auto-imports
autoImports.push({ name: 'useNuxt2Meta', as: 'useNuxt2Meta', from: '#app' })
})

View File

@ -160,15 +160,6 @@ function convertToLegacyMiddleware (middleware) {
}
}
export const addRouteMiddleware = (name: string, middleware: any, options: AddRouteMiddlewareOptions = {}) => {
const nuxtApp = useNuxtApp()
if (options.global) {
nuxtApp._middleware.global.push(middleware)
} else {
nuxtApp._middleware.named[name] = convertToLegacyMiddleware(middleware)
}
}
const isProcessingMiddleware = () => {
try {
if (useNuxtApp()._processingMiddleware) {
@ -207,3 +198,17 @@ export interface RouteMiddleware {
}
export const defineNuxtRouteMiddleware = (middleware: RouteMiddleware) => middleware
interface AddRouteMiddleware {
(name: string, middleware: RouteMiddleware, options?: AddRouteMiddlewareOptions): void
(middleware: RouteMiddleware): void
}
export const addRouteMiddleware: AddRouteMiddleware = (name: string | RouteMiddleware, middleware?: RouteMiddleware, options: AddRouteMiddlewareOptions = {}) => {
const nuxtApp = useNuxtApp()
if (options.global || typeof name === 'function') {
nuxtApp._middleware.global.push(typeof name === 'function' ? name : middleware)
} else {
nuxtApp._middleware.named[name] = convertToLegacyMiddleware(middleware)
}
}

View File

@ -8,3 +8,5 @@ export type { FetchResult, UseFetchOptions } from './fetch'
export { useCookie } from './cookie'
export type { CookieOptions, CookieRef } from './cookie'
export { useRequestHeaders } from './ssr'
export { abortNavigation, addRouteMiddleware, defineNuxtRouteMiddleware, navigateTo, useRoute, useRouter } from './router'
export type { AddRouteMiddlewareOptions, RouteMiddleware } from './router'

View File

@ -0,0 +1,65 @@
import type { Router, RouteLocationNormalizedLoaded, NavigationGuard, RouteLocationNormalized, RouteLocationRaw } from 'vue-router'
import { useNuxtApp } from '#app'
export const useRouter = () => {
return useNuxtApp()?.$router as Router
}
export const useRoute = () => {
return useNuxtApp()._route as RouteLocationNormalizedLoaded
}
export interface RouteMiddleware {
(to: RouteLocationNormalized, from: RouteLocationNormalized): ReturnType<NavigationGuard>
}
export const defineNuxtRouteMiddleware = (middleware: RouteMiddleware) => middleware
export interface AddRouteMiddlewareOptions {
global?: boolean
}
interface AddRouteMiddleware {
(name: string, middleware: RouteMiddleware, options?: AddRouteMiddlewareOptions): void
(middleware: RouteMiddleware): void
}
export const addRouteMiddleware: AddRouteMiddleware = (name: string | RouteMiddleware, middleware?: RouteMiddleware, options: AddRouteMiddlewareOptions = {}) => {
const nuxtApp = useNuxtApp()
if (options.global || typeof name === 'function') {
nuxtApp._middleware.global.push(typeof name === 'function' ? name : middleware)
} else {
nuxtApp._middleware.named[name] = middleware
}
}
const isProcessingMiddleware = () => {
try {
if (useNuxtApp()._processingMiddleware) {
return true
}
} catch {
// Within an async middleware
return true
}
return false
}
export const navigateTo = (to: RouteLocationRaw) => {
if (isProcessingMiddleware()) {
return to
}
const router: Router = process.server ? useRouter() : (window as any).$nuxt.$router
return router.push(to)
}
/** This will abort navigation within a Nuxt route middleware handler. */
export const abortNavigation = (err?: Error | string) => {
if (process.dev && !isProcessingMiddleware()) {
throw new Error('abortNavigation() is only usable inside a route middleware handler.')
}
if (err) {
throw err instanceof Error ? err : new Error(err)
}
return false
}

View File

@ -0,0 +1,234 @@
import { DefineComponent, reactive, h } from 'vue'
import { parseURL, parseQuery } from 'ufo'
import { NuxtApp } from '@nuxt/schema'
import { createError } from 'h3'
import { defineNuxtPlugin } from '..'
import { callWithNuxt } from '../nuxt'
declare module 'vue' {
export interface GlobalComponents {
NuxtLink: DefineComponent<{ to: String }>
}
}
interface Route {
/** Percentage encoded pathname section of the URL. */
path: string;
/** The whole location including the `search` and `hash`. */
fullPath: string;
/** Object representation of the `search` property of the current location. */
query: Record<string, any>;
/** Hash of the current location. If present, starts with a `#`. */
hash: string;
/** Name of the matched record */
name: string | null | undefined;
/** Object of decoded params extracted from the `path`. */
params: Record<string, any>;
/**
* The location we were initially trying to access before ending up
* on the current location.
*/
redirectedFrom: Route | undefined;
/** Merged `meta` properties from all of the matched route records. */
meta: Record<string, any>;
}
function getRouteFromPath (fullPath: string) {
const url = parseURL(fullPath.toString())
return {
path: url.pathname,
fullPath,
query: parseQuery(url.search),
hash: url.hash,
// stub properties for compat with vue-router
params: {},
name: undefined,
matched: [],
redirectedFrom: undefined,
meta: {}
}
}
type RouteGuardReturn = void | Error | string | false
interface RouteGuard {
(to: Route, from: Route): RouteGuardReturn | Promise<RouteGuardReturn>
}
interface RouterHooks {
'resolve:before': (to: Route, from: Route) => RouteGuardReturn | Promise<RouteGuardReturn>
'navigate:before': (to: Route, from: Route) => RouteGuardReturn | Promise<RouteGuardReturn>
'navigate:after': (to: Route, from: Route) => void | Promise<void>
'error': (err: any) => void | Promise<void>
}
interface Router {
currentRoute: Route
isReady: () => Promise<void>
options: {}
install: () => Promise<void>
// Navigation
push: (url: string) => Promise<void>
replace: (url: string) => Promise<void>
back: () => void
go: (delta: number) => void
forward: () => void
// Guards
beforeResolve: (guard: RouterHooks['resolve:before']) => () => void
beforeEach: (guard: RouterHooks['navigate:before']) => () => void
afterEach: (guard: RouterHooks['navigate:after']) => () => void
onError: (handler: RouterHooks['error']) => () => void
// Routes
resolve: (url: string) => Route
addRoute: (parentName: string, route: Route) => void
getRoutes: () => any[]
hasRoute: (name: string) => boolean
removeRoute: (name: string) => void
}
export default defineNuxtPlugin<{ route: Route, router: Router }>((nuxtApp) => {
const routes = []
const hooks: { [key in keyof RouterHooks]: RouterHooks[key][] } = {
'navigate:before': [],
'resolve:before': [],
'navigate:after': [],
error: []
}
const registerHook = <T extends keyof RouterHooks>(hook: T, guard: RouterHooks[T]) => {
hooks[hook].push(guard)
return () => hooks[hook].splice(hooks[hook].indexOf(guard), 1)
}
const route: Route = reactive(getRouteFromPath(process.client ? window.location.href : nuxtApp.ssrContext.url))
async function handleNavigation (url: string, replace?: boolean): Promise<void> {
if (process.dev && !hooks.error.length) {
console.warn('No error handlers registered to handle middleware errors. You can register an error handler with `router.onError()`')
}
try {
// Resolve route
const to = getRouteFromPath(url)
// Run beforeEach hooks
for (const middleware of hooks['navigate:before']) {
const result = await middleware(to, route)
// Cancel navigation
if (result === false || result instanceof Error) { return }
// Redirect
if (result) { return handleNavigation(result, true) }
}
for (const handler of hooks['resolve:before']) {
await handler(to, route)
}
// Perform navigation
Object.assign(route, to)
if (process.client) {
window.history[replace ? 'replaceState' : 'pushState']({}, '', url)
}
// Run afterEach hooks
for (const middleware of hooks['navigate:after']) {
await middleware(to, route)
}
} catch (err) {
for (const handler of hooks.error) {
await handler(err)
}
}
}
const router: Router = {
currentRoute: route,
isReady: () => Promise.resolve(),
//
options: {},
install: () => Promise.resolve(),
// Navigation
push: (url: string) => handleNavigation(url, false),
replace: (url: string) => handleNavigation(url, true),
back: () => window.history.go(-1),
go: (delta: number) => window.history.go(delta),
forward: () => window.history.go(1),
// Guards
beforeResolve: (guard: RouterHooks['resolve:before']) => registerHook('resolve:before', guard),
beforeEach: (guard: RouterHooks['navigate:before']) => registerHook('navigate:before', guard),
afterEach: (guard: RouterHooks['navigate:after']) => registerHook('navigate:after', guard),
onError: (handler: RouterHooks['error']) => registerHook('error', handler),
// Routes
resolve: getRouteFromPath,
addRoute: (parentName: string, route: Route) => { routes.push(route) },
getRoutes: () => routes,
hasRoute: (name: string) => routes.some(route => route.name === name),
removeRoute: (name: string) => {
const index = routes.findIndex(route => route.name === name)
if (index !== -1) {
routes.splice(index, 1)
}
}
}
if (process.client) {
window.addEventListener('popstate', (event) => {
const location = (event.target as Window).location
router.replace(location.href.replace(location.origin, ''))
})
}
nuxtApp._route = route
// Handle middleware
nuxtApp._middleware = nuxtApp._middleware || {
global: [],
named: {}
}
router.beforeEach(async (to, from) => {
to.meta = reactive(to.meta || {})
nuxtApp._processingMiddleware = true
type MiddlewareDef = string | RouteGuard
const middlewareEntries = new Set<MiddlewareDef>(nuxtApp._middleware.global)
for (const middleware of middlewareEntries) {
const result = await callWithNuxt(nuxtApp as NuxtApp, middleware, [to, from])
if (process.server) {
if (result === false || result instanceof Error) {
const error = result || createError({
statusMessage: `Route navigation aborted: ${nuxtApp.ssrContext.url}`
})
nuxtApp.ssrContext.error = error
throw error
}
}
if (result || result === false) { return result }
}
})
router.afterEach(() => {
delete nuxtApp._processingMiddleware
})
nuxtApp.vueApp.component('NuxtLink', {
functional: true,
props: { to: String },
setup: (props, { slots }) => () => h('a', { href: props.to, onClick: (e) => { e.preventDefault(); router.push(props.to) } }, slots)
})
if (process.server) {
nuxtApp.hooks.hookOnce('app:created', async () => {
await router.push(nuxtApp.ssrContext.url)
if (route.fullPath !== nuxtApp.ssrContext.url) {
nuxtApp.ssrContext.res.setHeader('Location', route.fullPath)
nuxtApp.ssrContext.res.statusCode = 301
nuxtApp.ssrContext.res.end()
}
})
}
return {
provide: {
route,
router
}
}
})

View File

@ -15,7 +15,13 @@ export const Nuxt3AutoImports: AutoImportSource[] = [
'useFetch',
'useLazyFetch',
'useCookie',
'useRequestHeaders'
'useRequestHeaders',
'useRouter',
'useRoute',
'defineNuxtRouteMiddleware',
'navigateTo',
'abortNavigation',
'addRouteMiddleware'
]
},
// #meta

View File

@ -15,8 +15,9 @@ export default defineNuxtModule({
const pagesDir = resolve(nuxt.options.srcDir, nuxt.options.dir.pages)
const runtimeDir = resolve(distDir, 'pages/runtime')
// Disable module if pages dir do not exists
// Disable module (and use universal router) if pages dir do not exists
if (!existsSync(pagesDir)) {
addPlugin(resolve(distDir, 'app/plugins/router'))
return
}
@ -47,19 +48,7 @@ export default defineNuxtModule({
})
nuxt.hook('autoImports:extend', (autoImports) => {
const composablesFile = resolve(runtimeDir, 'composables')
const composables = [
'useRouter',
'useRoute',
'defineNuxtRouteMiddleware',
'definePageMeta',
'navigateTo',
'abortNavigation',
'addRouteMiddleware'
]
for (const composable of composables) {
autoImports.push({ name: composable, as: composable, from: composablesFile })
}
autoImports.push({ name: 'definePageMeta', as: 'definePageMeta', from: resolve(runtimeDir, 'composables') })
})
// Extract macros from pages

View File

@ -1,14 +1,5 @@
import { KeepAliveProps, TransitionProps, UnwrapRef } from 'vue'
import type { Router, RouteLocationNormalizedLoaded, NavigationGuard, RouteLocationNormalized, RouteLocationRaw } from 'vue-router'
import { useNuxtApp } from '#app'
export const useRouter = () => {
return useNuxtApp().$router as Router
}
export const useRoute = () => {
return useNuxtApp()._route as RouteLocationNormalizedLoaded
}
import type { RouteLocationNormalizedLoaded } from 'vue-router'
export interface PageMeta {
[key: string]: any
@ -35,53 +26,3 @@ export const definePageMeta = (meta: PageMeta): void => {
warnRuntimeUsage('definePageMeta')
}
}
export interface RouteMiddleware {
(to: RouteLocationNormalized, from: RouteLocationNormalized): ReturnType<NavigationGuard>
}
export const defineNuxtRouteMiddleware = (middleware: RouteMiddleware) => middleware
export interface AddRouteMiddlewareOptions {
global?: boolean
}
export const addRouteMiddleware = (name: string, middleware: RouteMiddleware, options: AddRouteMiddlewareOptions = {}) => {
const nuxtApp = useNuxtApp()
if (options.global) {
nuxtApp._middleware.global.push(middleware)
} else {
nuxtApp._middleware.named[name] = middleware
}
}
const isProcessingMiddleware = () => {
try {
if (useNuxtApp()._processingMiddleware) {
return true
}
} catch {
// Within an async middleware
return true
}
return false
}
export const navigateTo = (to: RouteLocationRaw) => {
if (isProcessingMiddleware()) {
return to
}
const router: Router = process.server ? useRouter() : (window as any).$nuxt.$router
return router.push(to)
}
/** This will abort navigation within a Nuxt route middleware handler. */
export const abortNavigation = (err?: Error | string) => {
if (process.dev && !isProcessingMiddleware()) {
throw new Error('abortNavigation() is only usable inside a route middleware handler.')
}
if (err) {
throw err instanceof Error ? err : new Error(err)
}
return false
}

View File

@ -118,6 +118,8 @@ export default defineNuxtPlugin((nuxtApp) => {
router.afterEach((to) => {
if (to.fullPath !== nuxtApp.ssrContext.url) {
nuxtApp.ssrContext.res.setHeader('Location', to.fullPath)
nuxtApp.ssrContext.res.statusCode = 301
nuxtApp.ssrContext.res.end()
}
})
}

View File

@ -2,8 +2,8 @@ import { expectTypeOf } from 'expect-type'
import { describe, it } from 'vitest'
import type { Ref } from 'vue'
import { useRouter as vueUseRouter } from 'vue-router'
import { defineNuxtConfig } from '~~/../../packages/nuxt3/src'
import { NavigationFailure, RouteLocationNormalizedLoaded, RouteLocationRaw, useRouter as vueUseRouter } from 'vue-router'
import { defineNuxtConfig } from '~~/../../../packages/nuxt3/src'
import { useRouter } from '#imports'
import { isVue3 } from '#app'
@ -46,6 +46,19 @@ describe('middleware', () => {
// @ts-expect-error Invalid middleware
definePageMeta({ middleware: 'invalid-middleware' })
})
it('handles adding middleware', () => {
addRouteMiddleware('example', (to, from) => {
expectTypeOf(to).toMatchTypeOf<RouteLocationNormalizedLoaded>()
expectTypeOf(from).toMatchTypeOf<RouteLocationNormalizedLoaded>()
expectTypeOf(navigateTo).toMatchTypeOf<(to: RouteLocationRaw) => RouteLocationRaw | Promise<void | NavigationFailure>>()
navigateTo('/')
abortNavigation()
abortNavigation('error string')
abortNavigation(new Error('my error'))
// @ts-expect-error Must return error or string
abortNavigation(true)
}, { global: true })
})
})
describe('layouts', () => {

View File

@ -10774,6 +10774,15 @@ __metadata:
languageName: unknown
linkType: soft
"example-with-universal-router@workspace:examples/with-universal-router":
version: 0.0.0-use.local
resolution: "example-with-universal-router@workspace:examples/with-universal-router"
dependencies:
"@nuxt/ui": "npm:@nuxt/ui-edge@latest"
nuxt3: latest
languageName: unknown
linkType: soft
"example-with-wasm@workspace:examples/with-wasm":
version: 0.0.0-use.local
resolution: "example-with-wasm@workspace:examples/with-wasm"