Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions packages/nuxt-cli/src/commands/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,15 +231,22 @@ const command = defineCommand({
listenOverrides.showURL = false
}

const { listener, close, reload, onRestart, onReady, onLoading, onEachReady, onLog, onRequests, onRoutes, onBuilding, onFileChange } = await initialize({ cwd, args: ctx.args, handoverFrom: takeover.action === 'taken' ? takeover.pid : undefined }, {
const started = await initialize({ cwd, args: ctx.args, handoverFrom: takeover.action === 'taken' ? takeover.pid : undefined }, {
data: ctx.data,
listenOverrides,
showBanner: !ui,
captureUIEvents: ui,
onProgress: session && (snapshot => session.reportProgress(snapshot)),
onListening: session && (info => session.reportListening(info)),
}).catch((error: unknown) => {
// The panel is mid-startup and holding the terminal, so it has to give it
// back before the error is printed under it.
session?.teardown()
throw error
})

const { listener, close, reload, onRestart, onReady, onLoading, onEachReady, onLog, onRequests, onRoutes, onBuilding, onFileChange } = started

/** Feed the dev UI from the server running in this process. */
function attachDevUI(devUI: DevUIController): DevUIController {
onLoading(message => devUI.setStatus('building', message))
Expand Down Expand Up @@ -393,7 +400,7 @@ const command = defineCommand({
process.exit(1)
}
logger.error(`Could not restart the dev server, keeping the current one: ${detail}`)
devUI.setStatus('ready')
devUI.settleRestart()
onRestart(restart)
return
}
Expand Down
57 changes: 55 additions & 2 deletions packages/nuxt-cli/src/commands/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,59 @@
import type { CommandDef } from 'citty'
import type { CommandDef, SubCommandsDef } from 'citty'

const _rDefault = (r: any) => (r.default || r) as Promise<CommandDef>
import { asActionableError } from '../utils/errors'

type Resolvable<T> = T | Promise<T> | (() => T) | (() => Promise<T>)

function resolve<T>(value: Resolvable<T>): T | Promise<T> {
return typeof value === 'function' ? (value as () => T | Promise<T>)() : value
}

/**
* Carry the boundary into a command's subcommands, keeping each one lazy so
* wrapping a parent never loads them.
*
* The map is itself resolvable, and enumerating a function or a promise yields
* no entries, so it is resolved before being walked and stays a resolver where
* it began as one.
*/
function withRemedies(subCommands: Resolvable<SubCommandsDef>): Resolvable<SubCommandsDef> {
const wrapEntries = (resolved: SubCommandsDef): SubCommandsDef => Object.fromEntries(
Object.entries(resolved).map(([name, subCommand]) => [
name,
async () => withRemedy(await resolve(subCommand)),
]),
)
return typeof subCommands === 'function' || subCommands instanceof Promise
? async () => wrapEntries(await resolve(subCommands))
: wrapEntries(subCommands)
}

/**
* One error boundary for every command.
*
* A tagged error can be raised from any of the kit entry points a command
* touches, so the remedy is applied where the command ends rather than at each
* of them.
*/
export function withRemedy(command: CommandDef): CommandDef {
const { run, subCommands } = command
return {
...command,
...run && {
async run(context: Parameters<typeof run>[0]) {
try {
return await run(context)
}
catch (error) {
throw asActionableError(error)
}
},
},
...subCommands && { subCommands: withRemedies(subCommands) },
}
}

const _rDefault = (r: any) => withRemedy((r.default || r) as CommandDef) as unknown as Promise<CommandDef>

const commandLoaders = {
'add': () => import('./add').then(_rDefault),
Expand Down
7 changes: 7 additions & 0 deletions packages/nuxt-cli/src/dev/tui/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ export interface DevUIController {
/** Whether the interactive UI is active (rather than the plain fallback). */
interactive: boolean
setStatus: (status: DevStatus, note?: string) => void
/**
* Report that a restart is over without a new server to show for it, so the
* panel returns to describing the one still running. A failed load keeps its
* error, since that is what the surviving server is.
*/
settleRestart: () => void
/** Record a structured log event forwarded from the dev server fork. */
pushServerLog: (log: ForwardedLog) => void
/** Record a batch of served requests for the traffic ticker. */
Expand All @@ -31,6 +37,7 @@ export interface DevUIController {
export const NOOP_CONTROLLER: DevUIController = {
interactive: false,
setStatus: () => {},
settleRestart: () => {},
pushServerLog: () => {},
pushRequests: () => {},
setRoutes: () => {},
Expand Down
41 changes: 37 additions & 4 deletions packages/nuxt-cli/src/dev/tui/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ const ACTIVITY_MS = 700
/** How long passing feedback stays on the panel before it is dropped. */
const NOTICE_MS = 4000

/** Statuses that mean a load is in flight, so the server is not up yet. */
const LOADING_STATUSES = new Set<DevStatus>(['starting', 'building', 'restarting'])

interface UIShortcut {
keys: string[]
/** Short label for the hint line. Omitted shortcuts live only in the help view. */
Expand Down Expand Up @@ -132,6 +135,12 @@ export function setupDevUI(context: ShortcutContext, options: DevUIOptions = {})
let animationInterval = LOGO_FRAME_MS
let activityTimer: NodeJS.Timeout | undefined
let noticeTimer: NodeJS.Timeout | undefined
/**
* The load in flight raised an error, so the server never came up. Held apart
* from the counts, which are cumulative and so cannot say whether anything is
* wrong *now*.
*/
let loadFailed = false

function update(patch: Partial<PanelState>): void {
Object.assign(state, patch)
Expand Down Expand Up @@ -252,6 +261,7 @@ export function setupDevUI(context: ShortcutContext, options: DevUIOptions = {})
function clearHistory(): void {
events.clear()
requests.clear()
loadFailed = false
// With the history gone, the error badge would point at nothing.
update({ failures: 0, ...state.status === 'error' ? { status: 'ready' as DevStatus, note: undefined } : {} })
}
Expand All @@ -270,7 +280,17 @@ export function setupDevUI(context: ShortcutContext, options: DevUIOptions = {})
return
}
if (event.level <= 0) {
update({ errors: (state.errors ?? 0) + 1, status: state.status === 'ready' ? 'error' : state.status })
// An error raised while a load is in flight is that load failing, and
// nothing else reports that it has. One raised by a server already up
// belongs to the page it was serving.
loadFailed ||= LOADING_STATUSES.has(state.status)
update({
errors: (state.errors ?? 0) + 1,
status: 'error',
// The phase note describes work that is no longer happening, so the
// badge's own description takes the line back.
...state.status === 'error' ? {} : { note: undefined },
})
}
else if (event.level === 1) {
// A warning about what the CLI could not do says nothing about the app, so
Expand Down Expand Up @@ -323,6 +343,8 @@ export function setupDevUI(context: ShortcutContext, options: DevUIOptions = {})
process.emit('SIGINT' as any)
}

const settleRestart = () => update({ status: loadFailed ? 'error' : 'ready', note: undefined })

const restart = async (options: { clearCache?: boolean } = {}) => {
if (!context.restart) {
return
Expand All @@ -343,7 +365,11 @@ export function setupDevUI(context: ShortcutContext, options: DevUIOptions = {})
showNotice(`could not restart: ${error instanceof Error ? error.message : error}`, 'warn')
}
finally {
update({ status: 'ready' })
// A restart that got through, or whose load failed, has already reported
// itself through `setStatus`; only one nothing spoke for is left to settle.
if (state.status === 'restarting') {
settleRestart()
}
}
}

Expand Down Expand Up @@ -547,10 +573,12 @@ export function setupDevUI(context: ShortcutContext, options: DevUIOptions = {})

return {
interactive: true,
settleRestart,
setStatus: (status, note) => {
// The counts only describe what is currently wrong, so a successful load
// supersedes earlier build errors.
if (status === 'ready') {
loadFailed = false
Comment thread
coderabbitai[bot] marked this conversation as resolved.
update({ status, note: undefined, progress: undefined, phaseStartedAt: undefined, phaseElapsedMs: undefined, errors: 0, warnings: 0, failures: 0 })
return
}
Expand Down Expand Up @@ -582,12 +610,17 @@ export function setupDevUI(context: ShortcutContext, options: DevUIOptions = {})
// Nuxt answers a failed render with its error page rather than logging it,
// so the response status is the only signal that something is wrong.
const failing = (app.at(-1)?.status ?? 0) >= 500
const recovered = app.length > 0 && !failing && state.status === 'error' && !state.errors
// A page that failed is answered by the next one that does not, but only
// a load that gets through clears a failed load.
const recovered = app.length > 0 && !failing && state.status === 'error' && !loadFailed
// A request that failed because the load did is a symptom of it, and the
// load error is reported in the logs rather than against the request.
const failureNote = failing && !loadFailed ? 'a request failed 路 press n to trace it' : undefined
update({
active: true,
failures: (state.failures ?? 0) + failed.length,
status: failing ? 'error' : recovered ? 'ready' : state.status,
note: failing ? 'a request failed 路 press n to trace it' : recovered ? undefined : state.note,
note: failureNote ?? (recovered ? undefined : state.note),
})
repaintTicker()
clearTimeout(activityTimer)
Expand Down
5 changes: 2 additions & 3 deletions packages/nuxt-cli/src/dev/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -529,9 +529,8 @@ export class NuxtDevServer extends EventEmitter<DevServerEventMap> {
if (this.#rejectDisallowedHost(req, res)) {
return
}
if (this.options.captureUIEvents) {
this.#internalResponses.add(res)
}
// The error page answers a request the client made, so it stays in the
// request feed rather than counting as one the CLI answered itself.
// The recovery script makes the page reload itself once the next load
// starts, so a fixed file shows up without the reader touching anything.
await renderError(req, res, this.#loadingError, { inject: RECOVERY_SCRIPT })
Expand Down
23 changes: 23 additions & 0 deletions packages/nuxt-cli/src/utils/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,29 @@ export class ActionableError extends Error {
}
}

/**
* Re-raise a Nuxt error by the remedy it carries rather than by its stack.
*
* Nuxt tags the errors it raises with `fix`, the command that resolves it, and
* `docs`, where the rest is explained. Those frames are all inside
* `node_modules` or this CLI's own `dist`, so the remedy is the whole of what a
* reader can act on. An untagged error is returned as it came.
*/
export function asActionableError(error: unknown): unknown {
if (!(error instanceof Error)) {
return error
}
const { fix, docs } = error as Error & { fix?: unknown, docs?: unknown }
if (typeof fix !== 'string' || !fix.trim()) {
return error
}
const lines = [error.message, fix]
if (typeof docs === 'string' && docs.trim()) {
lines.push(`See ${docs}`)
}
return new ActionableError(lines.join('\n'))
}

/**
* Errors that say something about the other end of a connection rather than
* about this process: a broken pipe, a client hanging up mid-request, a tab
Expand Down
92 changes: 92 additions & 0 deletions packages/nuxt-cli/test/unit/command-remedy.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import type { CommandDef } from 'citty'

import { describe, expect, it } from 'vitest'

import { withRemedy } from '../../src/commands'
import { ActionableError } from '../../src/utils/errors'

function tagged(): Error {
return Object.assign(new Error('The module `@nuxt/image` could not be loaded. It may not be installed.'), {
code: 'NUXT_B8017',
fix: 'Run `npm install @nuxt/image` to install it.',
})
}

function run(command: CommandDef): Promise<unknown> {
return (command.run as (context: unknown) => Promise<unknown>)({ args: {}, cmd: command, rawArgs: [] })
}

/** Resolve a command's subcommand map however it was declared, as citty does. */
async function resolveMap(command: CommandDef): Promise<Record<string, unknown>> {
const { subCommands } = command
return await (typeof subCommands === 'function' ? subCommands() : subCommands) as Record<string, unknown>
}

async function subCommand(command: CommandDef, name: string): Promise<CommandDef> {
const entry = (await resolveMap(command))[name]
return await (typeof entry === 'function' ? entry() : entry) as CommandDef
}

describe('command error boundary', () => {
it('should present a tagged error by its remedy however far into the command it was raised', async () => {
const command = withRemedy({ meta: { name: 'build' }, run: async () => {
await Promise.resolve()
throw tagged()
} })

await expect(run(command)).rejects.toThrow(ActionableError)
await expect(run(command)).rejects.toThrow(/Run `npm install @nuxt\/image` to install it\./)
})

it('should leave an untagged error with its stack', async () => {
const parse = new Error('ParseError: Unexpected token')
const command = withRemedy({ meta: { name: 'build' }, run: () => Promise.reject(parse) })

await expect(run(command)).rejects.toBe(parse)
})

it('should carry the boundary into subcommands', async () => {
const command = withRemedy({
meta: { name: 'module' },
subCommands: { add: () => ({ meta: { name: 'add' }, run: () => Promise.reject(tagged()) }) },
})

await expect(run(await subCommand(command, 'add'))).rejects.toThrow(ActionableError)
})

it('should keep the subcommands of a command whose whole map is a resolver', async () => {
const command = withRemedy({
meta: { name: 'module' },
subCommands: () => ({ add: { meta: { name: 'add' }, run: () => Promise.reject(tagged()) } }),
})

expect(Object.keys(await resolveMap(command))).toEqual(['add'])
await expect(run(await subCommand(command, 'add'))).rejects.toThrow(ActionableError)
})

it('should keep the subcommands of a command whose map is a promise', async () => {
const command = withRemedy({
meta: { name: 'task' },
subCommands: Promise.resolve({ run: { meta: { name: 'run' }, run: () => Promise.reject(tagged()) } }),
})

expect(Object.keys(await resolveMap(command))).toEqual(['run'])
await expect(run(await subCommand(command, 'run'))).rejects.toThrow(ActionableError)
})

it('should leave a plain subcommand map as a plain object', async () => {
const command = withRemedy({
meta: { name: 'module' },
subCommands: { add: { meta: { name: 'add' }, run: () => Promise.resolve('ok') } },
})

expect(typeof command.subCommands).toBe('object')
await expect(run(await subCommand(command, 'add'))).resolves.toBe('ok')
})

it('should pass a resolved command through untouched when it cannot fail', async () => {
const command = withRemedy({ meta: { name: 'info' }, run: () => Promise.resolve('ok') })

await expect(run(command)).resolves.toBe('ok')
})
})
Loading
Loading