From dbab979a2ed28b8f3d02044079f6b9039114d5ff Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Mon, 21 Feb 2022 13:03:42 +0000 Subject: [PATCH] feat(nuxt3): add universal routing utilities (#3274) --- examples/with-universal-router/app.vue | 35 +++ examples/with-universal-router/nuxt.config.ts | 7 + examples/with-universal-router/package.json | 13 + examples/with-universal-router/plugins/add.ts | 33 +++ examples/with-universal-router/tsconfig.json | 3 + packages/bridge/src/auto-imports.ts | 8 - packages/bridge/src/runtime/composables.ts | 23 +- packages/nuxt3/src/app/composables/index.ts | 2 + packages/nuxt3/src/app/composables/router.ts | 65 +++++ packages/nuxt3/src/app/plugins/router.ts | 234 ++++++++++++++++++ packages/nuxt3/src/auto-imports/imports.ts | 8 +- packages/nuxt3/src/pages/module.ts | 17 +- .../nuxt3/src/pages/runtime/composables.ts | 61 +---- packages/nuxt3/src/pages/runtime/router.ts | 2 + test/fixtures/basic/types.ts | 17 +- yarn.lock | 9 + 16 files changed, 443 insertions(+), 94 deletions(-) create mode 100644 examples/with-universal-router/app.vue create mode 100644 examples/with-universal-router/nuxt.config.ts create mode 100644 examples/with-universal-router/package.json create mode 100644 examples/with-universal-router/plugins/add.ts create mode 100644 examples/with-universal-router/tsconfig.json create mode 100644 packages/nuxt3/src/app/composables/router.ts create mode 100644 packages/nuxt3/src/app/plugins/router.ts diff --git a/examples/with-universal-router/app.vue b/examples/with-universal-router/app.vue new file mode 100644 index 0000000000..91ca951239 --- /dev/null +++ b/examples/with-universal-router/app.vue @@ -0,0 +1,35 @@ + + + diff --git a/examples/with-universal-router/nuxt.config.ts b/examples/with-universal-router/nuxt.config.ts new file mode 100644 index 0000000000..9850816d15 --- /dev/null +++ b/examples/with-universal-router/nuxt.config.ts @@ -0,0 +1,7 @@ +import { defineNuxtConfig } from 'nuxt3' + +export default defineNuxtConfig({ + modules: [ + '@nuxt/ui' + ] +}) diff --git a/examples/with-universal-router/package.json b/examples/with-universal-router/package.json new file mode 100644 index 0000000000..3026a01a22 --- /dev/null +++ b/examples/with-universal-router/package.json @@ -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" + } +} diff --git a/examples/with-universal-router/plugins/add.ts b/examples/with-universal-router/plugins/add.ts new file mode 100644 index 0000000000..6677989d22 --- /dev/null +++ b/examples/with-universal-router/plugins/add.ts @@ -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' + }) +}) diff --git a/examples/with-universal-router/tsconfig.json b/examples/with-universal-router/tsconfig.json new file mode 100644 index 0000000000..4b34df1571 --- /dev/null +++ b/examples/with-universal-router/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "./.nuxt/tsconfig.json" +} diff --git a/packages/bridge/src/auto-imports.ts b/packages/bridge/src/auto-imports.ts index 503db32f16..9378fc2027 100644 --- a/packages/bridge/src/auto-imports.ts +++ b/packages/bridge/src/auto-imports.ts @@ -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' }) }) diff --git a/packages/bridge/src/runtime/composables.ts b/packages/bridge/src/runtime/composables.ts index 9e6d1d5b76..dfb2ebc23e 100644 --- a/packages/bridge/src/runtime/composables.ts +++ b/packages/bridge/src/runtime/composables.ts @@ -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) + } +} diff --git a/packages/nuxt3/src/app/composables/index.ts b/packages/nuxt3/src/app/composables/index.ts index 812e4996a4..d078732225 100644 --- a/packages/nuxt3/src/app/composables/index.ts +++ b/packages/nuxt3/src/app/composables/index.ts @@ -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' diff --git a/packages/nuxt3/src/app/composables/router.ts b/packages/nuxt3/src/app/composables/router.ts new file mode 100644 index 0000000000..f5daf0a345 --- /dev/null +++ b/packages/nuxt3/src/app/composables/router.ts @@ -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 +} + +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 +} diff --git a/packages/nuxt3/src/app/plugins/router.ts b/packages/nuxt3/src/app/plugins/router.ts new file mode 100644 index 0000000000..7ed61b5209 --- /dev/null +++ b/packages/nuxt3/src/app/plugins/router.ts @@ -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; + /** 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; + /** + * 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; +} + +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 +} + +interface RouterHooks { + 'resolve:before': (to: Route, from: Route) => RouteGuardReturn | Promise + 'navigate:before': (to: Route, from: Route) => RouteGuardReturn | Promise + 'navigate:after': (to: Route, from: Route) => void | Promise + 'error': (err: any) => void | Promise +} + +interface Router { + currentRoute: Route + isReady: () => Promise + options: {} + install: () => Promise + // Navigation + push: (url: string) => Promise + replace: (url: string) => Promise + 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 = (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 { + 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(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 + } + } +}) diff --git a/packages/nuxt3/src/auto-imports/imports.ts b/packages/nuxt3/src/auto-imports/imports.ts index d5f3ad8a16..c056650853 100644 --- a/packages/nuxt3/src/auto-imports/imports.ts +++ b/packages/nuxt3/src/auto-imports/imports.ts @@ -15,7 +15,13 @@ export const Nuxt3AutoImports: AutoImportSource[] = [ 'useFetch', 'useLazyFetch', 'useCookie', - 'useRequestHeaders' + 'useRequestHeaders', + 'useRouter', + 'useRoute', + 'defineNuxtRouteMiddleware', + 'navigateTo', + 'abortNavigation', + 'addRouteMiddleware' ] }, // #meta diff --git a/packages/nuxt3/src/pages/module.ts b/packages/nuxt3/src/pages/module.ts index 498089b17a..0527cc1974 100644 --- a/packages/nuxt3/src/pages/module.ts +++ b/packages/nuxt3/src/pages/module.ts @@ -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 diff --git a/packages/nuxt3/src/pages/runtime/composables.ts b/packages/nuxt3/src/pages/runtime/composables.ts index 6c123259c2..737c24f509 100644 --- a/packages/nuxt3/src/pages/runtime/composables.ts +++ b/packages/nuxt3/src/pages/runtime/composables.ts @@ -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 -} - -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 -} diff --git a/packages/nuxt3/src/pages/runtime/router.ts b/packages/nuxt3/src/pages/runtime/router.ts index 1b24bc11c7..6f612a4fab 100644 --- a/packages/nuxt3/src/pages/runtime/router.ts +++ b/packages/nuxt3/src/pages/runtime/router.ts @@ -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() } }) } diff --git a/test/fixtures/basic/types.ts b/test/fixtures/basic/types.ts index fcb7fe3118..7479dea005 100644 --- a/test/fixtures/basic/types.ts +++ b/test/fixtures/basic/types.ts @@ -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() + expectTypeOf(from).toMatchTypeOf() + expectTypeOf(navigateTo).toMatchTypeOf<(to: RouteLocationRaw) => RouteLocationRaw | Promise>() + navigateTo('/') + abortNavigation() + abortNavigation('error string') + abortNavigation(new Error('my error')) + // @ts-expect-error Must return error or string + abortNavigation(true) + }, { global: true }) + }) }) describe('layouts', () => { diff --git a/yarn.lock b/yarn.lock index bc90528325..cfbffe7536 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"