feat(nitro, nuxt3): allow handling otherwise unhandled runtime errors (#3464)

Co-authored-by: pooya parsa <pyapar@gmail.com>
This commit is contained in:
Daniel Roe 2022-03-11 08:22:16 +00:00 committed by GitHub
parent cff2f37cc8
commit 5d58ef48af
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
37 changed files with 451 additions and 97 deletions

View File

@ -0,0 +1,90 @@
# Error handling
## Handling errors
Nuxt 3 is a full-stack framework, which means there are several sources of unpreventable user runtime errors that can happen in different contexts:
1. Errors during the Vue rendering lifecycle (SSR + SPA)
1. Errors during API or Nitro server lifecycle
1. Server and client startup errors (SSR + SPA)
### Errors during the Vue rendering lifecycle (SSR + SPA)
You can hook into Vue errors using [`onErrorCaptured`](https://vuejs.org/api/composition-api-lifecycle.html#onerrorcaptured).
In addition, Nuxt provides a `vue:error` hook that will be called if there are any errors that propagate up to the top level.
If you are using a error reporting framework, you can provide a global handler through [`vueApp.config.errorHandler`](https://vuejs.org/api/application.html#app-config-errorhandler). It will receive all Vue errors, even if they are handled.
#### Example with global error reporting framework
```js
export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.vueApp.config.errorHandler = (error, context) => {
// ...
}
})
```
### Server and client startup errors (SSR + SPA)
Nuxt provides an `app:error` hook that will be called if there are any errors in starting your Nuxt application.
This includes:
* running Nuxt plugins
* processing `app:created` and `app:beforeMount` hooks
* mounting the app (on client-side), though you should handle this case with `onErrorCaptured` or with `vue:error`
* processing the `app:mounted` hook
### Errors during API or Nitro server lifecycle
You cannot currently define a server-side handler for these errors, but can render an error page (see the next section).
## Rendering an error page
When Nuxt encounters a fatal error, whether during the server lifecycle, or when rendering your Vue application (both SSR and SPA), it will either render a JSON response (if requested with `Accept: application/json` header) or an HTML error page.
You can customize this error page by adding `~/error.vue` in the source directory of your application, alongside `app.vue`. This page has a single prop - `error` which contains an error for you to handle.
When you are ready to remove the error page, you can call the `clearError` helper function, which takes an optional path to redirect to (for example, if you want to navigate to a 'safe' page).
::alert{type="warning"}
Make sure to check before using anything dependent on Nuxt plugins, such as `$route` or `useRouter`, as if a plugin threw an error, then it won't be re-run until you clear the error.
::
### Example
```vue [error.vue]
<template>
<button @click="handleError">Clear errors</button>
</template>
<script setup>
const props = defineProps({
error: Object
})
const handleError = () => clearError({ redirect: '/' })
</script>
```
## Error helper methods
### useError
* `function useError (): Ref<any>`
This function will return the global Nuxt error that is being handled.
### throwError
* `function throwError (err: string | Error): Error`
You can call this function at any point on client-side, or (on server side) directly within middleware, plugins or `setup()` functions. It will trigger a full-screen error page (as above) which you can clear with `clearError`.
### clearError
* `function clearError (redirect?: string): Promise<void>`
This function will clear the currently handled Nuxt error. It also takes an optional path to redirect to (for example, if you want to navigate to a 'safe' page).

View File

@ -0,0 +1,45 @@
<script setup lang="ts">
import { throwError } from '#app'
const route = useRoute()
if ('setup' in route.query) {
throw new Error('error in setup')
}
if ('mounted' in route.query) {
onMounted(() => {
throw new Error('error in mounted')
})
}
function triggerError () {
throw new Error('manually triggered error')
}
</script>
<template>
<NuxtExampleLayout example="with-errors">
<template #nav>
<nav class="flex align-center gap-4 p-4">
<NuxtLink to="/" class="n-link-base">
Home
</NuxtLink>
<NuxtLink to="/404" class="n-link-base">
404
</NuxtLink>
<NuxtLink to="/?middleware" class="n-link-base">
Middleware
</NuxtLink>
<button class="n-link-base" @click="throwError">
Trigger fatal error
</button>
<button class="n-link-base" @click="triggerError">
Trigger non-fatal error
</button>
</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,26 @@
<template>
<div class="relative font-sans" n="green6">
<div class="container max-w-200 mx-auto py-10 px-4">
<h1>{{ error.message }}</h1>
There was an error 😱
<button @click="handleError">
Clear error
</button>
<NuxtLink to="/404">
Trigger another error
</NuxtLink>
<NuxtLink to="/">
Navigate home
</NuxtLink>
</div>
</div>
</template>
<script setup lang="ts">
defineProps({
error: Object
})
const handleError = () => clearError({ redirect: '/' })
</script>

View File

@ -0,0 +1,5 @@
export default defineNuxtRouteMiddleware((to) => {
if ('middleware' in to.query) {
return throwError('error in middleware')
}
})

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-errors",
"private": true,
"scripts": {
"build": "nuxi build",
"dev": "nuxi dev",
"start": "nuxi preview"
},
"devDependencies": {
"@nuxt/ui": "npm:@nuxt/ui-edge@latest",
"nuxt3": "latest"
}
}

View File

View File

@ -0,0 +1,20 @@
export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.hook('vue:error', (..._args) => {
console.log('vue:error')
// if (process.client) {
// console.log(..._args)
// }
})
nuxtApp.hook('app:error', (..._args) => {
console.log('app:error')
// if (process.client) {
// console.log(..._args)
// }
})
nuxtApp.vueApp.config.errorHandler = (..._args) => {
console.log('global error handler')
// if (process.client) {
// console.log(..._args)
// }
}
})

View File

@ -0,0 +1,8 @@
import { useQuery, defineMiddleware } from 'h3'
export default defineMiddleware((req, res, next) => {
if ('api' in useQuery(req)) {
throw new Error('Server middleware error')
}
next()
})

View File

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

View File

@ -4,7 +4,7 @@ import type { Preset } from 'unimport'
import autoImports from '../../nuxt3/src/auto-imports/module'
import { vuePreset, commonPresets, appPreset } from '../../nuxt3/src/auto-imports/presets'
const UnsupportedImports = new Set(['useAsyncData', 'useFetch'])
const UnsupportedImports = new Set(['useAsyncData', 'useFetch', 'useError', 'throwError', 'clearError'])
const CapiHelpers = new Set(Object.keys(CompositionApi))
export function setupAutoImports () {

View File

@ -15,9 +15,9 @@
"dependencies": {
"@cloudflare/kv-asset-handler": "^0.2.0",
"@netlify/functions": "^1.0.0",
"@nuxt/design": "0.1.5",
"@nuxt/devalue": "^2.0.0",
"@nuxt/kit": "3.0.0",
"@nuxt/ui-templates": "npm:@nuxt/ui-templates-edge@latest",
"@rollup/plugin-alias": "^3.1.9",
"@rollup/plugin-commonjs": "^21.0.2",
"@rollup/plugin-inject": "^4.0.4",

View File

@ -1,6 +1,7 @@
import type { ServerResponse } from 'http'
import { createRenderer } from 'vue-bundle-renderer'
import devalue from '@nuxt/devalue'
import { useQuery } from 'h3'
import { privateConfig, publicConfig } from './config'
import { buildAssetsURL } from './paths'
// @ts-ignore
@ -70,7 +71,9 @@ function renderToString (ssrContext) {
}
export async function renderMiddleware (req, res: ServerResponse) {
let url = req.url
// Whether we're rendering an error page
const ssrError = req.url.startsWith('/__error') ? useQuery(req) : null
let url = ssrError?.url || req.url
// payload.json request detection
let isPayloadReq = false
@ -86,15 +89,23 @@ export async function renderMiddleware (req, res: ServerResponse) {
res,
runtimeConfig: { private: privateConfig, public: publicConfig },
noSSR: req.spa || req.headers['x-nuxt-no-ssr'],
...(req.context || {})
...(req.context || {}),
error: ssrError
}
// Render app
const rendered = await renderToString(ssrContext)
const rendered = await renderToString(ssrContext).catch((e) => {
if (!ssrError) { throw e }
})
// If we error on rendering error page, we bail out and directly return to the error handler
if (!rendered) { return }
// Handle errors
if (ssrContext.error) {
throw ssrContext.error
const error = ssrContext.error /* nuxt 3 */ || ssrContext.nuxt?.error /* nuxt 2 */
if (error && !ssrError) {
// trigger a re-render by onError handler
throw error
}
if (ssrContext.redirected || res.writableEnded) {
@ -107,7 +118,6 @@ export async function renderMiddleware (req, res: ServerResponse) {
// TODO: nuxt3 should not reuse `nuxt` property for different purpose!
const payload = ssrContext.payload /* nuxt 3 */ || ssrContext.nuxt /* nuxt 2 */
if (process.env.NUXT_FULL_STATIC) {
payload.staticAssetsBase = STATIC_ASSETS_BASE
}
@ -121,8 +131,7 @@ export async function renderMiddleware (req, res: ServerResponse) {
res.setHeader('Content-Type', 'text/html;charset=UTF-8')
}
const error = ssrContext.nuxt && ssrContext.nuxt.error
res.statusCode = error ? error.statusCode : 200
res.statusCode = res.statusCode || 200
res.end(data, 'utf-8')
}

View File

@ -1,13 +1,14 @@
// import ansiHTML from 'ansi-html'
import type { IncomingMessage, ServerResponse } from 'http'
import { error500, error404, errorDev } from '@nuxt/design'
import { withQuery } from 'ufo'
import { $fetch } from '.'
const cwd = process.cwd()
const hasReqHeader = (req, header, includes) => req.headers[header] && req.headers[header].toLowerCase().includes(includes)
const isDev = process.env.NODE_ENV === 'development'
export function handleError (error, req: IncomingMessage, res: ServerResponse) {
export async function handleError (error, req: IncomingMessage, res: ServerResponse) {
const isJsonRequest = hasReqHeader(req, 'accept', 'application/json') || hasReqHeader(req, 'user-agent', 'curl/') || hasReqHeader(req, 'user-agent', 'httpie/')
const stack = (error.stack || '')
@ -31,18 +32,17 @@ export function handleError (error, req: IncomingMessage, res: ServerResponse) {
const is404 = error.statusCode === 404
const errorObject = {
url: req.url,
statusCode: error.statusCode || 500,
statusMessage: is404 ? 'Page Not Found' : 'Internal Server Error',
statusMessage: error.statusMessage ?? is404 ? 'Page Not Found' : 'Internal Server Error',
message: error.message || error.toString(),
description: isDev && !is404
? `
<h1>${error.message}</h1>
<pre>${stack.map(i => `<span class="stack${i.internal ? ' internal' : ''}">${i.text}</span>`).join('\n')}</pre>
`
? `<pre>${stack.map(i => `<span class="stack${i.internal ? ' internal' : ''}">${i.text}</span>`).join('\n')}</pre>`
: ''
}
res.statusCode = error.statusCode || 500
res.statusMessage = error.statusMessage || 'Internal Server Error'
res.statusCode = errorObject.statusCode
res.statusMessage = errorObject.statusMessage
// Console output
if (!is404) {
@ -56,8 +56,9 @@ export function handleError (error, req: IncomingMessage, res: ServerResponse) {
}
// HTML response
const errorTemplate = is404 ? error404 : (isDev ? errorDev : error500)
const html = errorTemplate(errorObject)
const url = withQuery('/_nitro/__error', errorObject)
const html = await $fetch(url).catch(() => errorObject.statusMessage)
res.setHeader('Content-Type', 'text/html;charset=UTF-8')
res.end(html)
}

View File

@ -1,4 +1,4 @@
import { createApp, useBase } from 'h3'
import { createApp, lazyHandle, useBase } from 'h3'
import { createFetch, Headers } from 'ohmyfetch'
import destr from 'destr'
import { createCall, createFetch as createLocalFetch } from 'unenv/runtime/fetch/index'
@ -13,9 +13,12 @@ const app = createApp({
onError: handleError
})
const renderMiddleware = lazyHandle(() => import('../app/render').then(e => e.renderMiddleware))
app.use('/_nitro', renderMiddleware)
app.use(timingMiddleware)
app.use(serverMiddleware)
app.use(() => import('../app/render').then(e => e.renderMiddleware), { lazy: true })
app.use(renderMiddleware)
export const stack = app.stack
export const handle = useBase(baseURL(), app)

View File

@ -2,7 +2,7 @@ import { Worker } from 'worker_threads'
import { IncomingMessage, ServerResponse } from 'http'
import { existsSync, promises as fsp } from 'fs'
import { loading as loadingTemplate } from '@nuxt/design'
import { loading as loadingTemplate } from '@nuxt/ui-templates'
import chokidar, { FSWatcher } from 'chokidar'
import debounce from 'p-debounce'
import { promisifyHandle, createApp, Middleware, useBase } from 'h3'

View File

@ -17,9 +17,9 @@
"prepack": "unbuild"
},
"devDependencies": {
"@nuxt/design": "0.1.5",
"@nuxt/kit": "3.0.0",
"@nuxt/schema": "3.0.0",
"@nuxt/ui-templates": "npm:@nuxt/ui-templates-edge@latest",
"@types/clear": "^0",
"@types/mri": "^1.1.1",
"@types/rimraf": "^3",

View File

@ -86,7 +86,7 @@ export default defineNuxtCommand({
dLoad(true, `Directory \`${dir}/\` ${event === 'addDir' ? 'created' : 'removed'}`)
}
} else if (isFileChange) {
if (file.match(/app\.(js|ts|mjs|jsx|tsx|vue)$/)) {
if (file.match(/(app|error)\.(js|ts|mjs|jsx|tsx|vue)$/)) {
dLoad(true, `\`${relative(rootDir, file)}\` ${event === 'add' ? 'created' : 'removed'}`)
}
}

View File

@ -1,6 +1,6 @@
import type { RequestListener } from 'http'
import type { ListenOptions } from 'listhen'
import { loading } from '@nuxt/design'
import { loading } from '@nuxt/ui-templates'
export function createServer (defaultApp?) {
const listener = createDynamicFunction(defaultApp || createLoadingHandler('Loading...'))

View File

@ -30,10 +30,10 @@
"prepack": "unbuild"
},
"dependencies": {
"@nuxt/design": "^0.1.5",
"@nuxt/kit": "3.0.0",
"@nuxt/nitro": "3.0.0",
"@nuxt/schema": "3.0.0",
"@nuxt/ui-templates": "npm:@nuxt/ui-templates-edge@latest",
"@nuxt/vite-builder": "3.0.0",
"@vue/reactivity": "^3.2.31",
"@vue/shared": "^3.2.31",

View File

@ -0,0 +1,42 @@
<template>
<ErrorTemplate v-bind="{ statusCode, statusMessage, description, stack }" />
</template>
<script setup lang="ts">
import Error404 from '@nuxt/ui-templates/templates/error-404.vue'
import Error500 from '@nuxt/ui-templates/templates/error-500.vue'
import ErrorDev from '@nuxt/ui-templates/templates/error-dev.vue'
const props = defineProps({
error: Object
})
const error = props.error
// TODO: extract to a separate utility
const stacktrace = (error.stack || '')
.split('\n')
.splice(1)
.map((line) => {
const text = line
.replace('webpack:/', '')
.replace('.vue', '.js') // TODO: Support sourcemap
.trim()
return {
text,
internal: (line.includes('node_modules') && !line.includes('.cache')) ||
line.includes('internal') ||
line.includes('new Promise')
}
}).map(i => `<span class="stack${i.internal ? ' internal' : ''}">${i.text}</span>`).join('\n')
// Error page props
const statusCode = String(error.statusCode || 500)
const is404 = statusCode === '404'
const statusMessage = error.statusMessage ?? is404 ? 'Page Not Found' : 'Internal Server Error'
const description = error.message || error.toString()
const stack = process.dev && !is404 ? error.description || `<pre>${stacktrace}</pre>` : undefined
const ErrorTemplate = is404 ? Error404 : process.dev ? ErrorDev : Error500
</script>

View File

@ -1,22 +1,31 @@
<template>
<Suspense @resolve="onResolve">
<App />
<ErrorComponent v-if="error" :error="error" />
<App v-else />
</Suspense>
</template>
<script>
import { useNuxtApp } from '#app'
<script setup lang="ts">
import { onErrorCaptured } from 'vue'
import { callWithNuxt, throwError, useError, useNuxtApp } from '#app'
// @ts-ignore
import ErrorComponent from '#build/error-component.mjs'
export default {
setup () {
const nuxtApp = useNuxtApp()
const results = nuxtApp.hooks.callHookWith(hooks => hooks.map(hook => hook()), 'vue:setup')
if (process.dev && results && results.some(i => i && 'then' in i)) {
console.error('[nuxt] Error in `vue:setup`. Callbacks must be synchronous.')
}
return {
onResolve: () => nuxtApp.callHook('app:suspense:resolve')
}
}
const nuxtApp = useNuxtApp()
const onResolve = () => nuxtApp.callHook('app:suspense:resolve')
// vue:setup hook
const results = nuxtApp.hooks.callHookWith(hooks => hooks.map(hook => hook()), 'vue:setup')
if (process.dev && results && results.some(i => i && 'then' in i)) {
console.error('[nuxt] Error in `vue:setup`. Callbacks must be synchronous.')
}
// error handling
const error = useError()
onErrorCaptured((err, target, info) => {
nuxtApp.hooks.callHook('vue:error', err, target, info).catch(hookError => console.error('[nuxt] Error in `vue:error` hook', hookError))
if (process.server) {
callWithNuxt(nuxtApp, throwError, [err])
}
})
</script>

View File

@ -1,13 +0,0 @@
<template>
<!-- eslint-disable-next-line vue/no-v-html -->
<div v-html="html" />
</template>
<script>
import { welcome as welcomeTemplate } from '@nuxt/design'
export default ({
computed: {
html: () => welcomeTemplate({})
}
})
</script>

View File

@ -0,0 +1,30 @@
import { useNuxtApp, useState } from '#app'
export const useError = () => {
const nuxtApp = useNuxtApp()
return useState('error', () => process.server ? nuxtApp.ssrContext.error : nuxtApp.payload.error)
}
export const throwError = (_err: string | Error) => {
const nuxtApp = useNuxtApp()
const error = useError()
const err = typeof _err === 'string' ? new Error(_err) : _err
nuxtApp.callHook('app:error', err)
if (process.server) {
nuxtApp.ssrContext.error = nuxtApp.ssrContext.error || err
} else {
error.value = error.value || err
}
return err
}
export const clearError = async (options: { redirect?: string } = {}) => {
const nuxtApp = useNuxtApp()
const error = useError()
nuxtApp.callHook('app:error:cleared', options)
if (options.redirect) {
await nuxtApp.$router.replace(options.redirect)
}
error.value = null
}

View File

@ -3,6 +3,7 @@ export { useAsyncData, useLazyAsyncData } from './asyncData'
export type { AsyncDataOptions, AsyncData } from './asyncData'
export { useHydration } from './hydrate'
export { useState } from './state'
export { clearError, throwError, useError } from './error'
export { useFetch, useLazyFetch } from './fetch'
export type { FetchResult, UseFetchOptions } from './fetch'
export { useCookie } from './cookie'

View File

@ -19,9 +19,13 @@ if (process.server) {
const nuxt = createNuxtApp({ vueApp, ssrContext })
await applyPlugins(nuxt, plugins)
await nuxt.hooks.callHook('app:created', vueApp)
try {
await applyPlugins(nuxt, plugins)
await nuxt.hooks.callHook('app:created', vueApp)
} catch (err) {
await nuxt.callHook('app:error', err)
ssrContext.error = ssrContext.error || err
}
return vueApp
}
@ -43,19 +47,27 @@ if (process.client) {
const nuxt = createNuxtApp({ vueApp })
await applyPlugins(nuxt, plugins)
await nuxt.hooks.callHook('app:created', vueApp)
await nuxt.hooks.callHook('app:beforeMount', vueApp)
nuxt.hooks.hookOnce('app:suspense:resolve', () => {
nuxt.isHydrating = false
})
vueApp.mount('#__nuxt')
try {
await applyPlugins(nuxt, plugins)
} catch (err) {
await nuxt.callHook('app:error', err)
nuxt.payload.error = nuxt.payload.error || err
}
await nuxt.hooks.callHook('app:mounted', vueApp)
await nextTick()
try {
await nuxt.hooks.callHook('app:created', vueApp)
await nuxt.hooks.callHook('app:beforeMount', vueApp)
vueApp.mount('#__nuxt')
await nuxt.hooks.callHook('app:mounted', vueApp)
await nextTick()
} catch (err) {
await nuxt.callHook('app:error', err)
nuxt.payload.error = nuxt.payload.error || err
}
}
entry().catch((error) => {

View File

@ -1,6 +1,6 @@
/* eslint-disable no-use-before-define */
import { getCurrentInstance, reactive } from 'vue'
import type { App, VNode } from 'vue'
import type { App, onErrorCaptured, VNode } from 'vue'
import { createHooks, Hookable } from 'hookable'
import type { RuntimeConfig } from '@nuxt/schema'
import { legacyPlugin, LegacyContext } from './compat/legacy-app'
@ -21,10 +21,13 @@ export interface RuntimeNuxtHooks {
'app:mounted': (app: App<Element>) => HookResult
'app:rendered': () => HookResult
'app:suspense:resolve': (Component?: VNode) => HookResult
'app:error': (err: any) => HookResult
'app:error:cleared': (options: { redirect?: string }) => HookResult
'page:start': (Component?: VNode) => HookResult
'page:finish': (Component?: VNode) => HookResult
'meta:register': (metaRenderers: Array<(nuxt: NuxtApp) => NuxtMeta | Promise<NuxtMeta>>) => HookResult
'vue:setup': () => void
'vue:error': (...args: Parameters<Parameters<typeof onErrorCaptured>[0]>) => HookResult
}
interface _NuxtApp {

View File

@ -4,6 +4,7 @@ import { NuxtApp } from '@nuxt/schema'
import { createError } from 'h3'
import { defineNuxtPlugin } from '..'
import { callWithNuxt } from '../nuxt'
import { clearError, throwError } from '#app'
declare module 'vue' {
export interface GlobalComponents {
@ -106,6 +107,12 @@ export default defineNuxtPlugin<{ route: Route, router: Router }>((nuxtApp) => {
try {
// Resolve route
const to = getRouteFromPath(url)
if (process.client && !nuxtApp.isHydrating) {
// Clear any existing errors
await callWithNuxt(nuxtApp as NuxtApp, clearError)
}
// Run beforeEach hooks
for (const middleware of hooks['navigate:before']) {
const result = await middleware(to, route)
@ -196,8 +203,7 @@ export default defineNuxtPlugin<{ route: Route, router: Router }>((nuxtApp) => {
const error = result || createError({
statusMessage: `Route navigation aborted: ${nuxtApp.ssrContext.url}`
})
nuxtApp.ssrContext.error = error
throw error
return callWithNuxt(nuxtApp, throwError, [error])
}
}
if (result || result === false) { return result }

View File

@ -37,7 +37,10 @@ export const appPreset = defineUnimportPreset({
'defineNuxtRouteMiddleware',
'navigateTo',
'abortNavigation',
'addRouteMiddleware'
'addRouteMiddleware',
'throwError',
'clearError',
'useError'
]
})

View File

@ -2,7 +2,7 @@ import { promises as fsp } from 'fs'
import { dirname, resolve } from 'pathe'
import defu from 'defu'
import type { Nuxt, NuxtApp, NuxtPlugin } from '@nuxt/schema'
import { findPath, resolveFiles, normalizePlugin, normalizeTemplate, compileTemplate, templateUtils } from '@nuxt/kit'
import { findPath, resolveFiles, normalizePlugin, normalizeTemplate, compileTemplate, templateUtils, tryResolveModule } from '@nuxt/kit'
import * as defaultTemplates from './templates'
@ -59,12 +59,17 @@ export async function resolveApp (nuxt: Nuxt, app: NuxtApp) {
app.mainComponent = await findPath(['~/App', '~/app'])
}
if (!app.mainComponent) {
app.mainComponent = resolve(nuxt.options.appDir, 'components/nuxt-welcome.vue')
app.mainComponent = tryResolveModule('@nuxt/ui-templates/templates/welcome.vue')
}
// Default root component
app.rootComponent = resolve(nuxt.options.appDir, 'components/nuxt-root.vue')
// Resolve error component
if (!app.errorComponent) {
app.errorComponent = (await findPath(['~/error'])) || resolve(nuxt.options.appDir, 'components/nuxt-error-page.vue')
}
// Resolve plugins
for (const config of [...nuxt.options._extends.map(layer => layer.config), nuxt.options]) {
app.plugins.push(...[

View File

@ -10,10 +10,13 @@ export async function build (nuxt: Nuxt) {
if (nuxt.options.dev) {
watch(nuxt)
nuxt.hook('builder:watch', async (event, path) => {
if (event !== 'change' && /app|plugins/i.test(path)) {
if (event !== 'change' && /app|error|plugins/i.test(path)) {
if (path.match(/app/i)) {
app.mainComponent = null
}
if (path.match(/error/i)) {
app.errorComponent = null
}
await generateApp(nuxt, app)
}
})

View File

@ -1,7 +1,7 @@
import { resolve } from 'pathe'
import { createHooks } from 'hookable'
import type { Nuxt, NuxtOptions, NuxtConfig, ModuleContainer, NuxtHooks } from '@nuxt/schema'
import { loadNuxtConfig, LoadNuxtOptions, nuxtCtx, installModule, addComponent, addVitePlugin, addWebpackPlugin } from '@nuxt/kit'
import { loadNuxtConfig, LoadNuxtOptions, nuxtCtx, installModule, addComponent, addVitePlugin, addWebpackPlugin, tryResolveModule } from '@nuxt/kit'
// Temporary until finding better placement
/* eslint-disable import/no-restricted-paths */
import pagesModule from '../pages/module'
@ -76,7 +76,7 @@ async function initNuxt (nuxt: Nuxt) {
// Add <NuxtWelcome>
addComponent({
name: 'NuxtWelcome',
filePath: resolve(nuxt.options.appDir, 'components/nuxt-welcome.vue')
filePath: tryResolveModule('@nuxt/ui-templates/templates/welcome.vue')
})
// Add <ClientOnly>
@ -108,6 +108,7 @@ export async function loadNuxt (opts: LoadNuxtOptions): Promise<Nuxt> {
options._majorVersion = 3
options._modules.push(pagesModule, metaModule, componentsModule, autoImportsModule)
options.modulesDir.push(resolve(pkgDir, 'node_modules'))
options.build.transpile.push('@nuxt/ui-templates')
options.alias['vue-demi'] = resolve(options.appDir, 'compat/vue-demi')
options.alias['@vue/composition-api'] = resolve(options.appDir, 'compat/capi')

View File

@ -33,6 +33,11 @@ export const rootComponentTemplate = {
filename: 'root-component.mjs',
getContents: (ctx: TemplateContext) => genExport(ctx.app.rootComponent, ['default'])
}
// TODO: Use an alias
export const errorComponentTemplate = {
filename: 'error-component.mjs',
getContents: (ctx: TemplateContext) => genExport(ctx.app.errorComponent, ['default'])
}
export const cssTemplate = {
filename: 'css.mjs',

View File

@ -42,7 +42,7 @@ export default defineNuxtModule({
nuxt.hook('app:resolve', (app) => {
// Add default layout for pages
if (app.mainComponent.includes('nuxt-welcome')) {
if (app.mainComponent.includes('@nuxt/ui-templates')) {
app.mainComponent = resolve(runtimeDir, 'app.vue')
}
})

View File

@ -9,7 +9,7 @@ import {
import { createError } from 'h3'
import NuxtPage from './page'
import NuxtLayout from './layout'
import { callWithNuxt, defineNuxtPlugin, useRuntimeConfig, NuxtApp } from '#app'
import { callWithNuxt, defineNuxtPlugin, useRuntimeConfig, NuxtApp, throwError, clearError } from '#app'
// @ts-ignore
import routes from '#build/routes'
// @ts-ignore
@ -86,6 +86,11 @@ export default defineNuxtPlugin((nuxtApp) => {
}
}
if (process.client && !nuxtApp.isHydrating) {
// Clear any existing errors
await callWithNuxt(nuxtApp as NuxtApp, clearError)
}
for (const entry of middlewareEntries) {
const middleware = typeof entry === 'string' ? nuxtApp._middleware.named[entry] || await namedMiddleware[entry]?.().then(r => r.default || r) : entry
@ -96,9 +101,10 @@ export default defineNuxtPlugin((nuxtApp) => {
const result = await callWithNuxt(nuxtApp as NuxtApp, middleware, [to, from])
if (process.server) {
if (result === false || result instanceof Error) {
return result || createError({
const error = result || createError({
statusMessage: `Route navigation aborted: ${nuxtApp.ssrContext.url}`
})
return callWithNuxt(nuxtApp, throwError, [error])
}
}
if (result || result === false) { return result }
@ -110,6 +116,15 @@ export default defineNuxtPlugin((nuxtApp) => {
})
nuxtApp.hook('app:created', async () => {
router.afterEach((to) => {
if (to.matched.length === 0) {
callWithNuxt(nuxtApp, throwError, [createError({
statusCode: 404,
statusMessage: `Page not found: ${to.fullPath}`
})])
}
})
if (process.server) {
router.push(nuxtApp.ssrContext.url)
@ -124,16 +139,8 @@ export default defineNuxtPlugin((nuxtApp) => {
try {
await router.isReady()
const is404 = router.currentRoute.value.matched.length === 0
if (process.server && is404) {
throw createError({
statusCode: 404,
statusMessage: `Page not found: ${nuxtApp.ssrContext.url}`
})
}
} catch (error) {
nuxtApp.ssrContext.error = error
callWithNuxt(nuxtApp, throwError, [error])
}
})

View File

@ -53,6 +53,7 @@ export interface NuxtPlugin {
export interface NuxtApp {
mainComponent?: string
rootComponent?: string
errorComponent?: string
dir: string
extensions: string[]
plugins: NuxtPlugin[]

View File

@ -2892,13 +2892,6 @@ __metadata:
languageName: node
linkType: hard
"@nuxt/design@npm:0.1.5, @nuxt/design@npm:^0.1.5":
version: 0.1.5
resolution: "@nuxt/design@npm:0.1.5"
checksum: 1e75ba0c7e9519754d185133e1bc97d2056201b2c6ad99a9196ffb94e787a4ee9fa2a64b1dd868febece5b3370c8a50388033b65455edc939fe67bcc1f4bbe8b
languageName: node
linkType: hard
"@nuxt/devalue@npm:^1.2.5":
version: 1.2.5
resolution: "@nuxt/devalue@npm:1.2.5"
@ -3035,10 +3028,10 @@ __metadata:
dependencies:
"@cloudflare/kv-asset-handler": ^0.2.0
"@netlify/functions": ^1.0.0
"@nuxt/design": 0.1.5
"@nuxt/devalue": ^2.0.0
"@nuxt/kit": 3.0.0
"@nuxt/schema": 3.0.0
"@nuxt/ui-templates": "npm:@nuxt/ui-templates-edge@latest"
"@rollup/plugin-alias": ^3.1.9
"@rollup/plugin-commonjs": ^21.0.2
"@rollup/plugin-inject": ^4.0.4
@ -3294,6 +3287,13 @@ __metadata:
languageName: node
linkType: hard
"@nuxt/ui-templates@npm:@nuxt/ui-templates-edge@latest":
version: 0.0.0-27449202.0cb54cc
resolution: "@nuxt/ui-templates-edge@npm:0.0.0-27449202.0cb54cc"
checksum: bbc709d35bf1c586bc604835caf8b6000e4cab22ad56d71d231a7d4e987ab8dd33a9adbba11af684f70826007ad3094480f38524160bf7c6f1a9dc1fcb375f79
languageName: node
linkType: hard
"@nuxt/ui@npm:@nuxt/ui-edge@latest":
version: 0.0.0-27376194.a859489
resolution: "@nuxt/ui-edge@npm:0.0.0-27376194.a859489"
@ -10570,6 +10570,15 @@ __metadata:
languageName: unknown
linkType: soft
"example-with-errors@workspace:examples/with-errors":
version: 0.0.0-use.local
resolution: "example-with-errors@workspace:examples/with-errors"
dependencies:
"@nuxt/ui": "npm:@nuxt/ui-edge@latest"
nuxt3: latest
languageName: unknown
linkType: soft
"example-with-layouts@workspace:examples/with-layouts":
version: 0.0.0-use.local
resolution: "example-with-layouts@workspace:examples/with-layouts"
@ -15369,9 +15378,9 @@ __metadata:
version: 0.0.0-use.local
resolution: "nuxi@workspace:packages/nuxi"
dependencies:
"@nuxt/design": 0.1.5
"@nuxt/kit": 3.0.0
"@nuxt/schema": 3.0.0
"@nuxt/ui-templates": "npm:@nuxt/ui-templates-edge@latest"
"@types/clear": ^0
"@types/mri": ^1.1.1
"@types/rimraf": ^3
@ -15476,10 +15485,10 @@ __metadata:
version: 0.0.0-use.local
resolution: "nuxt3@workspace:packages/nuxt3"
dependencies:
"@nuxt/design": ^0.1.5
"@nuxt/kit": 3.0.0
"@nuxt/nitro": 3.0.0
"@nuxt/schema": 3.0.0
"@nuxt/ui-templates": "npm:@nuxt/ui-templates-edge@latest"
"@nuxt/vite-builder": 3.0.0
"@types/fs-extra": ^9.0.13
"@types/hash-sum": ^1.0.0