diff --git a/knip.json b/knip.json index fbcb79d1f..46b548390 100644 --- a/knip.json +++ b/knip.json @@ -35,7 +35,8 @@ "pkg-types", "scule", "semver", - "ufo" + "ufo", + "youch" ] } } diff --git a/packages/nuxi/package.json b/packages/nuxi/package.json index 34e4b9902..4b652d323 100644 --- a/packages/nuxi/package.json +++ b/packages/nuxi/package.json @@ -74,6 +74,7 @@ "ufo": "^1.5.4", "unbuild": "^3.5.0", "unplugin-purge-polyfills": "^0.0.7", - "vitest": "^3.0.9" + "vitest": "^3.0.9", + "youch": "^4.1.0-beta.6" } } diff --git a/packages/nuxi/src/commands/dev-child.ts b/packages/nuxi/src/commands/dev-child.ts index bc9c890c2..b6711272b 100644 --- a/packages/nuxi/src/commands/dev-child.ts +++ b/packages/nuxi/src/commands/dev-child.ts @@ -77,6 +77,14 @@ export default defineCommand({ devContext, }) + nuxtDev.on('loading:error', (_error) => { + sendIPCMessage({ type: 'nuxt:internal:dev:loading:error', error: { + message: _error.message, + stack: _error.stack, + name: _error.name, + code: _error.code, + } }) + }) nuxtDev.on('loading', (message) => { sendIPCMessage({ type: 'nuxt:internal:dev:loading', message }) }) diff --git a/packages/nuxi/src/commands/dev.ts b/packages/nuxi/src/commands/dev.ts index 10e340828..11c89b2fe 100644 --- a/packages/nuxi/src/commands/dev.ts +++ b/packages/nuxi/src/commands/dev.ts @@ -20,6 +20,7 @@ import { isBun, isTest } from 'std-env' import { showVersions } from '../utils/banner' import { _getDevServerDefaults, _getDevServerOverrides } from '../utils/dev' import { overrideEnv } from '../utils/env' +import { renderError } from '../utils/error' import { loadKit } from '../utils/kit' import { logger } from '../utils/logger' import { cwdArgs, dotEnvArgs, envNameArgs, legacyRootDirArgs, logLevelArgs } from './_shared' @@ -123,6 +124,7 @@ type DevProxy = Awaited> async function _createDevProxy(nuxtOptions: NuxtOptions, listenOptions: Partial) { const jiti = createJiti(nuxtOptions.rootDir) let loadingMessage = 'Nuxt dev server is starting...' + let error: Error | undefined let loadingTemplate = nuxtOptions.devServer.loadingTemplate for (const url of nuxtOptions.modulesDir) { // @ts-expect-error this is for backwards compatibility @@ -138,6 +140,10 @@ async function _createDevProxy(nuxtOptions: NuxtOptions, listenOptions: Partial< let address: string | undefined const handler = (req: IncomingMessage, res: ServerResponse) => { + if (error) { + renderError(req, res, error) + return + } if (!address) { res.statusCode = 503 res.setHeader('Content-Type', 'text/html') @@ -169,6 +175,12 @@ async function _createDevProxy(nuxtOptions: NuxtOptions, listenOptions: Partial< setLoadingMessage: (_msg: string) => { loadingMessage = _msg }, + setError: (_error: Error) => { + error = _error + }, + clearError() { + error = undefined + }, } } @@ -183,6 +195,7 @@ async function _startSubprocess(devProxy: DevProxy, rawArgs: string[], listenArg } const restart = async () => { + devProxy.clearError() // Kill previous process with restart signal (not supported on Windows) if (process.platform === 'win32') { kill('SIGTERM') @@ -226,6 +239,11 @@ async function _startSubprocess(devProxy: DevProxy, rawArgs: string[], listenArg else if (message.type === 'nuxt:internal:dev:loading') { devProxy.setAddress(undefined) devProxy.setLoadingMessage(message.message) + devProxy.clearError() + } + else if (message.type === 'nuxt:internal:dev:loading:error') { + devProxy.setAddress(undefined) + devProxy.setError(message.error) } else if (message.type === 'nuxt:internal:dev:restart') { restart() diff --git a/packages/nuxi/src/utils/dev.ts b/packages/nuxi/src/utils/dev.ts index 7934f4a83..b4e9e4596 100644 --- a/packages/nuxi/src/utils/dev.ts +++ b/packages/nuxi/src/utils/dev.ts @@ -2,7 +2,7 @@ import type { Nuxt, NuxtConfig } from '@nuxt/schema' import type { FSWatcher } from 'chokidar' import type { Jiti } from 'jiti' import type { HTTPSOptions, Listener, ListenOptions, ListenURL } from 'listhen' -import type { RequestListener, ServerResponse } from 'node:http' +import type { IncomingMessage, RequestListener, ServerResponse } from 'node:http' import type { AddressInfo } from 'node:net' import EventEmitter from 'node:events' @@ -22,12 +22,14 @@ import { clearBuildDir } from '../utils/fs' import { loadKit } from '../utils/kit' import { logger } from '../utils/logger' import { loadNuxtManifest, resolveNuxtManifest, writeNuxtManifest } from '../utils/nuxt' +import { renderError } from './error' export type NuxtDevIPCMessage = | { type: 'nuxt:internal:dev:ready', port: number } | { type: 'nuxt:internal:dev:loading', message: string } | { type: 'nuxt:internal:dev:restart' } | { type: 'nuxt:internal:dev:rejection', message: string } + | { type: 'nuxt:internal:dev:loading:error', error: Error } export interface NuxtDevContext { public?: boolean @@ -53,10 +55,7 @@ interface NuxtDevServerOptions { devContext: NuxtDevContext } -export async function createNuxtDevServer( - options: NuxtDevServerOptions, - listenOptions?: Partial, -) { +export async function createNuxtDevServer(options: NuxtDevServerOptions, listenOptions?: Partial) { // Initialize dev server const devServer = new NuxtDevServer(options) @@ -88,8 +87,7 @@ export async function createNuxtDevServer( } // https://regex101.com/r/7HkR5c/1 -const RESTART_RE - = /^(?:nuxt\.config\.[a-z0-9]+|\.nuxtignore|\.nuxtrc|\.config\/nuxt(?:\.config)?\.[a-z0-9]+)$/ +const RESTART_RE = /^(?:nuxt\.config\.[a-z0-9]+|\.nuxtignore|\.nuxtrc|\.config\/nuxt(?:\.config)?\.[a-z0-9]+)$/ class NuxtDevServer extends EventEmitter { private _handler?: RequestListener @@ -97,6 +95,7 @@ class NuxtDevServer extends EventEmitter { private _currentNuxt?: Nuxt private _loadingMessage?: string private _jiti: Jiti + private _loadingError?: Error loadDebounced: (reload?: boolean, reason?: string) => void handler: RequestListener @@ -118,12 +117,16 @@ class NuxtDevServer extends EventEmitter { this._jiti = createJiti(options.cwd) this.handler = async (req, res) => { + if (this._loadingError) { + this._renderError(req, res) + return + } await _initPromise if (this._handler) { this._handler(req, res) } else { - this._renderLoadingScreen(res) + this._renderLoadingScreen(req, res) } } @@ -131,7 +134,11 @@ class NuxtDevServer extends EventEmitter { this.listener = undefined } - async _renderLoadingScreen(res: ServerResponse) { + _renderError(req: IncomingMessage, res: ServerResponse) { + renderError(req, res, this._loadingError) + } + + async _renderLoadingScreen(req: IncomingMessage, res: ServerResponse) { res.statusCode = 503 res.setHeader('Content-Type', 'text/html') const loadingTemplate @@ -154,13 +161,14 @@ class NuxtDevServer extends EventEmitter { async load(reload?: boolean, reason?: string) { try { await this._load(reload, reason) + this._loadingError = undefined } catch (error) { logger.error(`Cannot ${reload ? 'restart' : 'start'} nuxt: `, error) this._handler = undefined - this._loadingMessage - = 'Error while loading Nuxt. Please check console and fix errors.' - this.emit('loading', this._loadingMessage) + this._loadingError = error as Error + this._loadingMessage = 'Error while loading Nuxt. Please check console and fix errors.' + this.emit('loading:error', error) } } @@ -270,28 +278,19 @@ class NuxtDevServer extends EventEmitter { ) } - await this._currentNuxt.hooks.callHook( - 'listen', - this.listener.server, - this.listener, - ) + await this._currentNuxt.hooks.callHook('listen', this.listener.server, this.listener) // Sync internal server info to the internals // It is important for vite-node to use the internal URL but public proto const addr = this.listener.address this._currentNuxt.options.devServer.host = addr.address this._currentNuxt.options.devServer.port = addr.port - this._currentNuxt.options.devServer.url = _getAddressURL( - addr, - !!this.listener.https, - ) + this._currentNuxt.options.devServer.url = _getAddressURL(addr, !!this.listener.https) this._currentNuxt.options.devServer.https = this.options.devContext.proxy ?.https as boolean | { key: string, cert: string } if (this.listener.https && !process.env.NODE_TLS_REJECT_UNAUTHORIZED) { - logger.warn( - 'You might need `NODE_TLS_REJECT_UNAUTHORIZED=0` environment variable to make https work.', - ) + logger.warn('You might need `NODE_TLS_REJECT_UNAUTHORIZED=0` environment variable to make https work.') } await Promise.all([ @@ -300,10 +299,10 @@ class NuxtDevServer extends EventEmitter { ]) // Watch dist directory - this._distWatcher = chokidar.watch( - resolve(this._currentNuxt.options.buildDir, 'dist'), - { ignoreInitial: true, depth: 0 }, - ) + this._distWatcher = chokidar.watch(resolve(this._currentNuxt.options.buildDir, 'dist'), { + ignoreInitial: true, + depth: 0, + }) this._distWatcher.on('unlinkDir', () => { this.loadDebounced(true, '.nuxt/dist directory has been removed') }) @@ -313,13 +312,10 @@ class NuxtDevServer extends EventEmitter { } async _watchConfig() { - const configWatcher = chokidar.watch( - [this.options.cwd, join(this.options.cwd, '.config')], - { - ignoreInitial: true, - depth: 0, - }, - ) + const configWatcher = chokidar.watch([this.options.cwd, join(this.options.cwd, '.config')], { + ignoreInitial: true, + depth: 0, + }) configWatcher.on('all', (event, _file) => { if (event === 'all' || event === 'ready' || event === 'error' || event === 'raw') { return diff --git a/packages/nuxi/src/utils/error.ts b/packages/nuxi/src/utils/error.ts new file mode 100644 index 000000000..da5161de9 --- /dev/null +++ b/packages/nuxi/src/utils/error.ts @@ -0,0 +1,16 @@ +import type { IncomingMessage, ServerResponse } from 'node:http' +import { Youch } from 'youch' + +export async function renderError(req: IncomingMessage, res: ServerResponse, error: unknown) { + const youch = new Youch() + res.statusCode = 500 + res.setHeader('Content-Type', 'text/html') + const html = await youch.toHTML(error, { + request: { + url: req.url, + method: req.method, + headers: req.headers, + }, + }) + res.end(html) +} diff --git a/packages/nuxt-cli/package.json b/packages/nuxt-cli/package.json index a99f77ff7..77cab9ca1 100644 --- a/packages/nuxt-cli/package.json +++ b/packages/nuxt-cli/package.json @@ -55,7 +55,8 @@ "semver": "^7.7.1", "std-env": "^3.8.1", "tinyexec": "^1.0.1", - "ufo": "^1.5.4" + "ufo": "^1.5.4", + "youch": "^4.1.0-beta.6" }, "devDependencies": { "@types/node": "^22.13.14", @@ -64,6 +65,7 @@ "typescript": "^5.8.2", "unbuild": "^3.5.0", "unplugin-purge-polyfills": "^0.0.7", - "vitest": "^3.0.9" + "vitest": "^3.0.9", + "youch": "^4.1.0-beta.6" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 58bf7d86e..9232f0495 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -203,6 +203,9 @@ importers: vitest: specifier: ^3.0.9 version: 3.0.9(@types/debug@4.1.12)(@types/node@22.13.14)(jiti@2.4.2)(terser@5.37.0)(yaml@2.7.0) + youch: + specifier: ^4.1.0-beta.6 + version: 4.1.0-beta.6 packages/nuxt-cli: dependencies: @@ -275,6 +278,9 @@ importers: ufo: specifier: ^1.5.4 version: 1.5.4 + youch: + specifier: ^4.1.0-beta.6 + version: 4.1.0-beta.6 devDependencies: '@types/node': specifier: ^22.13.14