Skip to content

Commit 1f9ea29

Browse files
authored
feat: middleware support (#59)
1 parent d7a5dbd commit 1f9ea29

File tree

6 files changed

+220
-33
lines changed

6 files changed

+220
-33
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@
8484
"lint-staged": "^13.0.3",
8585
"nuxt": "^3.0.0-rc.8",
8686
"pinst": "^3.0.0",
87+
"playwright": "^1.25.2",
8788
"prettier": "^2.7.1",
8889
"release-it": "^15.4.1",
8990
"typescript": "^4.8.2",
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export default defineNuxtRouteMiddleware(to => {
2+
console.log('ran middleware')
3+
})

pnpm-lock.yaml

Lines changed: 17 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/parts/router.ts

Lines changed: 5 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { existsSync } from 'node:fs'
2-
import { useNuxt, useLogger, addPlugin } from '@nuxt/kit'
2+
import { useNuxt, useLogger } from '@nuxt/kit'
33
import { join, resolve } from 'pathe'
44
import { runtimeDir } from '../utils'
55

@@ -21,7 +21,6 @@ export const setupRouter = () => {
2121
return
2222
}
2323

24-
addPlugin(resolve(runtimeDir, 'router'))
2524
nuxt.options.vite.optimizeDeps = nuxt.options.vite.optimizeDeps || {}
2625
nuxt.options.vite.optimizeDeps.include = nuxt.options.vite.optimizeDeps.include || []
2726
nuxt.options.vite.optimizeDeps.include.push('@ionic/vue-router')
@@ -31,32 +30,13 @@ export const setupRouter = () => {
3130
app.plugins = app.plugins.filter(
3231
p => !p.src.match(/nuxt3?\/dist\/(app\/plugins|pages\/runtime)\/router/)
3332
)
33+
app.plugins.unshift({
34+
src: resolve(runtimeDir, 'router'),
35+
mode: 'all',
36+
})
3437
})
3538
})
3639

37-
// Remove Nuxt useRoute & useRouter composables
38-
nuxt.hook('autoImports:sources', sources => {
39-
for (const source of sources) {
40-
if (source.from === '#app') {
41-
source.imports = source.imports.filter(
42-
i => typeof i !== 'string' || !['useRoute', 'useRouter'].includes(i)
43-
)
44-
}
45-
}
46-
sources.push({
47-
from: 'vue-router',
48-
imports: ['useRouter', 'useRoute'],
49-
})
50-
})
51-
52-
// Remove vue-router types
53-
nuxt.hook('prepare:types', ({ references }) => {
54-
const index = references.findIndex(i => 'types' in i && i.types === 'vue-router')
55-
if (index !== -1) {
56-
references.splice(index, 1)
57-
}
58-
})
59-
6040
// Add default ionic root layout
6141
nuxt.hook('app:resolve', app => {
6242
if (

src/runtime/router.ts

Lines changed: 181 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,195 @@
11
import { createRouter, createWebHistory, createMemoryHistory } from '@ionic/vue-router'
22

3-
import { defineNuxtPlugin, useRuntimeConfig } from '#imports'
3+
import { computed, ComputedRef, reactive, shallowRef } from 'vue'
4+
import { createWebHashHistory, NavigationGuard, RouteLocation } from 'vue-router'
5+
import { createError } from 'h3'
6+
import { withoutBase, isEqual } from 'ufo'
7+
import {
8+
callWithNuxt,
9+
defineNuxtPlugin,
10+
useRuntimeConfig,
11+
showError,
12+
clearError,
13+
navigateTo,
14+
useError,
15+
useState,
16+
} from '#app'
417

18+
// @ts-expect-error virtual module
19+
import { globalMiddleware, namedMiddleware } from '#build/middleware'
520
// @ts-expect-error virtual module
621
import routerOptions from '#build/router.options'
722
// @ts-expect-error virtual module generated by Nuxt
8-
import routes from '#build/routes'
23+
import _routes from '#build/routes'
24+
25+
export default defineNuxtPlugin(async nuxtApp => {
26+
let routerBase = useRuntimeConfig().app.baseURL
27+
if (routerOptions.hashMode && !routerBase.includes('#')) {
28+
// allow the user to provide a `#` in the middle: `/base/#/app`
29+
routerBase += '#'
30+
}
31+
32+
const history =
33+
routerOptions.history?.(routerBase) ??
34+
(process.client
35+
? routerOptions.hashMode
36+
? createWebHashHistory(routerBase)
37+
: createWebHistory(routerBase)
38+
: createMemoryHistory(routerBase))
939

10-
export default defineNuxtPlugin(nuxtApp => {
11-
const config = useRuntimeConfig()
12-
const baseURL = config.app.baseURL
40+
const routes = routerOptions.routes?.(_routes) ?? _routes
1341

42+
const initialURL = process.server
43+
? nuxtApp.ssrContext!.url
44+
: createCurrentLocation(routerBase, window.location)
1445
const router = createRouter({
1546
...routerOptions,
16-
history: process.server ? createMemoryHistory(baseURL) : createWebHistory(baseURL),
47+
history,
1748
routes,
1849
})
19-
2050
nuxtApp.vueApp.use(router)
51+
const previousRoute = shallowRef(router.currentRoute.value)
52+
router.afterEach((_to, from) => {
53+
previousRoute.value = from
54+
})
55+
56+
Object.defineProperty(nuxtApp.vueApp.config.globalProperties, 'previousRoute', {
57+
get: () => previousRoute.value,
58+
})
59+
60+
// Allows suspending the route object until page navigation completes
61+
const _route = shallowRef(router.resolve(initialURL) as RouteLocation)
62+
const syncCurrentRoute = () => {
63+
_route.value = router.currentRoute.value
64+
}
65+
nuxtApp.hook('page:finish', syncCurrentRoute)
66+
router.afterEach((to, from) => {
67+
// We won't trigger suspense if the component is reused between routes
68+
// so we need to update the route manually
69+
if (to.matched[0]?.components?.default === from.matched[0]?.components?.default) {
70+
syncCurrentRoute()
71+
}
72+
})
73+
74+
// https://github.com/vuejs/router/blob/main/packages/router/src/router.ts#L1225-L1233
75+
const route = {} as { [K in keyof RouteLocation]: ComputedRef<RouteLocation[K]> }
76+
for (const key in _route.value) {
77+
route[key as 'path'] = computed(() => _route.value[key as 'path'])
78+
}
79+
80+
nuxtApp._route = reactive(route)
81+
82+
nuxtApp._middleware = nuxtApp._middleware || {
83+
global: [],
84+
named: {},
85+
}
86+
87+
const error = useError()
88+
89+
const initialLayout = useState<string>('_layout')
90+
router.beforeEach(async (to, from) => {
91+
to.meta = reactive(to.meta)
92+
if (nuxtApp.isHydrating) {
93+
to.meta.layout = initialLayout.value ?? to.meta.layout
94+
}
95+
nuxtApp._processingMiddleware = true
96+
97+
type MiddlewareDef = string | NavigationGuard
98+
const middlewareEntries = new Set<MiddlewareDef>([
99+
...globalMiddleware,
100+
...nuxtApp._middleware.global,
101+
])
102+
for (const component of to.matched) {
103+
const componentMiddleware = component.meta.middleware as MiddlewareDef | MiddlewareDef[]
104+
if (!componentMiddleware) {
105+
continue
106+
}
107+
if (Array.isArray(componentMiddleware)) {
108+
for (const entry of componentMiddleware) {
109+
middlewareEntries.add(entry)
110+
}
111+
} else {
112+
middlewareEntries.add(componentMiddleware)
113+
}
114+
}
115+
116+
for (const entry of middlewareEntries) {
117+
const middleware =
118+
typeof entry === 'string'
119+
? nuxtApp._middleware.named[entry] ||
120+
(await namedMiddleware[entry]?.().then((r: any) => r.default || r))
121+
: entry
122+
123+
if (!middleware) {
124+
if (process.dev) {
125+
throw new Error(
126+
`Unknown route middleware: '${entry}'. Valid middleware: ${Object.keys(namedMiddleware)
127+
.map(mw => `'${mw}'`)
128+
.join(', ')}.`
129+
)
130+
}
131+
throw new Error(`Unknown route middleware: '${entry}'.`)
132+
}
133+
134+
const result = await callWithNuxt(nuxtApp, middleware, [to, from])
135+
if (process.server || (!nuxtApp.payload.serverRendered && nuxtApp.isHydrating)) {
136+
if (result === false || result instanceof Error) {
137+
const error =
138+
result ||
139+
createError({
140+
statusMessage: `Route navigation aborted: ${initialURL}`,
141+
})
142+
return callWithNuxt(nuxtApp, showError, [error])
143+
}
144+
}
145+
if (result || result === false) {
146+
return result
147+
}
148+
}
149+
})
150+
151+
router.afterEach(async to => {
152+
delete nuxtApp._processingMiddleware
153+
154+
if (process.client && !nuxtApp.isHydrating && error.value) {
155+
// Clear any existing errors
156+
await callWithNuxt(nuxtApp, clearError)
157+
}
158+
if (to.matched.length === 0) {
159+
callWithNuxt(nuxtApp, showError, [
160+
createError({
161+
statusCode: 404,
162+
fatal: false,
163+
statusMessage: `Page not found: ${to.fullPath}`,
164+
}),
165+
])
166+
} else if (process.server && to.matched[0].name === '404' && nuxtApp.ssrContext) {
167+
nuxtApp.ssrContext.event.res.statusCode = 404
168+
} else if (process.server) {
169+
const currentURL = to.fullPath || '/'
170+
if (!isEqual(currentURL, initialURL)) {
171+
await callWithNuxt(nuxtApp, navigateTo, [currentURL])
172+
}
173+
}
174+
})
175+
176+
return { provide: { router } }
21177
})
178+
179+
// https://github.com/vuejs/router/blob/4a0cc8b9c1e642cdf47cc007fa5bbebde70afc66/packages/router/src/history/html5.ts#L37
180+
function createCurrentLocation (base: string, location: Location): string {
181+
const { pathname, search, hash } = location
182+
// allows hash bases like #, /#, #/, #!, #!/, /#!/, or even /folder#end
183+
const hashPos = base.indexOf('#')
184+
if (hashPos > -1) {
185+
const slicePos = hash.includes(base.slice(hashPos)) ? base.slice(hashPos).length : 1
186+
let pathFromHash = hash.slice(slicePos)
187+
// prepend the starting slash to hash so the url starts with /#
188+
if (pathFromHash[0] !== '/') {
189+
pathFromHash = '/' + pathFromHash
190+
}
191+
return withoutBase(pathFromHash, '')
192+
}
193+
const path = withoutBase(pathname, base)
194+
return path + search + hash
195+
}

test/e2e/ssr.spec.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
/* @vitest-environment node */
22
import { fileURLToPath } from 'node:url'
3-
import { setup, $fetch } from '@nuxt/test-utils'
3+
import { setup, $fetch, createPage, url } from '@nuxt/test-utils'
44
import { describe, expect, it } from 'vitest'
55

66
describe('nuxt ionic', async () => {
77
await setup({
88
server: true,
9+
browser: true,
910
rootDir: fileURLToPath(new URL('../../playground', import.meta.url)),
1011
})
1112

@@ -22,4 +23,15 @@ describe('nuxt ionic', async () => {
2223
'<meta name="viewport" content="viewport-fit=cover, width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no">'
2324
)
2425
})
26+
27+
it('runs middleware on client-side', async () => {
28+
const logs: string[] = []
29+
const page = await createPage()
30+
page.on('console', msg => {
31+
logs.push(msg.text())
32+
})
33+
await page.goto(url('/tabs/tab1'))
34+
await page.waitForLoadState('networkidle')
35+
expect(logs).toContain('ran middleware')
36+
})
2537
})

0 commit comments

Comments
 (0)