Skip to content

Commit 2dcd7bf

Browse files
committed
fix: tighten error surfacing, restart failures + line truncation 🐰
1 parent f41ea97 commit 2dcd7bf

13 files changed

Lines changed: 190 additions & 44 deletions

File tree

packages/nuxt-cli/src/dev/force-tty.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ if (process.env.__NUXT_DEV_PIPED_TTY__) {
1818
get: () => Number(process.env.__NUXT_DEV_COLUMNS__) || 80,
1919
configurable: true,
2020
})
21-
const depth = Number(process.env.__NUXT_DEV_COLOR_DEPTH__) || (process.env.FORCE_COLOR ? 8 : 1)
21+
const depth = Number(process.env.__NUXT_DEV_COLOR_DEPTH__) || forcedColorDepth()
2222
Object.defineProperty(stream, 'getColorDepth', { value: () => depth, configurable: true })
2323
Object.defineProperty(stream, 'hasColors', {
2424
value: (count?: number) => depth >= 4 && (typeof count !== 'number' || count <= 2 ** depth),
@@ -38,3 +38,22 @@ if (process.env.__NUXT_DEV_PIPED_TTY__) {
3838
}
3939
}
4040
}
41+
42+
/** The depth `FORCE_COLOR` asks for, read the way Node reads it. */
43+
function forcedColorDepth(): number {
44+
const forced = process.env.FORCE_COLOR?.trim().toLowerCase()
45+
switch (forced) {
46+
case undefined:
47+
case '':
48+
case '0':
49+
case 'false':
50+
return 1
51+
case '1':
52+
case 'true':
53+
return 4
54+
case '3':
55+
return 24
56+
default:
57+
return 8
58+
}
59+
}

packages/nuxt-cli/src/dev/pool.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,9 @@ export class ForkPool {
6969
process.stdout.on('resize', () => {
7070
for (const fork of this.pool) {
7171
if (fork.state !== 'dead' && fork.process.connected) {
72-
fork.process.send({ type: 'nuxt:internal:dev:resize', columns: process.stdout.columns || 80 } satisfies NuxtParentIPCMessage)
72+
// A fork can die between the check and the send, and this runs from
73+
// a `resize` event where a throw would end the session.
74+
fork.process.send({ type: 'nuxt:internal:dev:resize', columns: process.stdout.columns || 80 } satisfies NuxtParentIPCMessage, () => {})
7375
}
7476
}
7577
})

packages/nuxt-cli/src/dev/tui/controller.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,21 @@
1+
import type { ServerLogEvent } from '../log-channel'
12
import type { ShortcutContext } from '../shortcuts'
2-
import type { DevRoutes } from '../utils'
3+
import type { DevRequestEvent, DevRoutes } from '../utils'
34
import type { DevUIOptions } from './index'
45
import type { DevStatus } from './panel'
56
import type { DevUISession } from './session'
67

8+
/** A {@link ServerLogEvent} from a source that may not know where it came from. */
9+
type ForwardedLog = Omit<ServerLogEvent, 'origin'> & { origin?: ServerLogEvent['origin'] }
10+
711
export interface DevUIController {
812
/** Whether the interactive UI is active (rather than the plain fallback). */
913
interactive: boolean
1014
setStatus: (status: DevStatus, note?: string) => void
1115
/** Record a structured log event forwarded from the dev server fork. */
12-
pushServerLog: (log: { level: number, logType: string, tag?: string, message: string, origin?: 'build' | 'runtime', request?: string, requestId?: number }) => void
16+
pushServerLog: (log: ForwardedLog) => void
1317
/** Record a batch of served requests for the traffic ticker. */
14-
pushRequests: (requests: Array<{ id?: number, method: string, url: string, status: number, duration: number, internal?: boolean }>) => void
18+
pushRequests: (requests: DevRequestEvent[]) => void
1519
/** Replace the routes shown in the route view. */
1620
setRoutes: (routes: DevRoutes) => void
1721
}

packages/nuxt-cli/src/dev/tui/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,11 @@ export function setupDevUI(context: ShortcutContext, options: DevUIOptions = {})
243243
}
244244
await context.restart()
245245
}
246+
catch (error) {
247+
// A restart that throws must not take the session with it: both call
248+
// sites dispatch it from a keypress, where nothing is awaiting.
249+
showNotice(`could not restart: ${error instanceof Error ? error.message : error}`, 'warn')
250+
}
246251
finally {
247252
update({ status: 'ready' })
248253
}

packages/nuxt-cli/src/dev/tui/route-overlay.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { styleText } from 'node:util'
99
import { link } from 'clickable-path'
1010

1111
import { formatHints, ScreenOverlay } from './screen'
12+
import { truncate } from './width'
1213

1314
type RouteFilter = 'all' | 'page' | 'server'
1415

@@ -116,9 +117,11 @@ export class RouteOverlay extends ScreenOverlay {
116117
: styleText('magenta', (route.method?.toUpperCase() ?? 'all').padEnd(6))
117118
const path = route.route.padEnd(width)
118119
const file = route.file ? relative(this.#cwd, route.file) : ''
120+
// The tail of a path identifies it; the head is the part every row shares.
119121
const room = columns - width - 10
120-
const label = file.length > room ? `…${file.slice(-room + 1)}` : file
121-
return ` ${kind} ${path} ${styleText('dim', route.file ? link(route.file, { cwd: this.#cwd, formatter: () => label }) : label)}`
122+
const label = room < 8 ? '' : file.length > room ? `…${file.slice(1 - room)}` : file
123+
const target = label && route.file ? link(route.file, { cwd: this.#cwd, formatter: () => label }) : label
124+
return truncate(` ${kind} ${path} ${styleText('dim', target)}`, columns)
122125
}
123126

124127
#matching(): DevRoute[] {

packages/nuxt-cli/src/dev/tui/session.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,11 @@ const SHOW_CURSOR = '\u001B[?25h'
2727
/** Long enough for a forwarded log to be paired with its printed output. */
2828
const ERROR_SURFACE_DELAY_MS = 60
2929

30+
/** An error as it belongs in scrollback: as printed, or as reported. */
31+
function renderErrorLine(event: DevLogEvent): string {
32+
return event.rendered ?? `${styleText(['red', 'bold'], 'ERROR')} ${event.message}`
33+
}
34+
3035
export interface DevUISession {
3136
surface: PanelSurface
3237
events: DevEventLog
@@ -221,8 +226,9 @@ export function beginDevUI(options: DevUISupportOptions & { version?: string, cw
221226
}
222227
event.surfaced = true
223228
lastSurfacedError = text
224-
const timer = setTimeout(() => {
225-
surfaceText(event.rendered ?? `${styleText(['red', 'bold'], 'ERROR')} ${event.message}`)
229+
const timer: NodeJS.Timeout = setTimeout(() => {
230+
pendingErrors.delete(timer)
231+
surfaceText(renderErrorLine(event))
226232
}, ERROR_SURFACE_DELAY_MS)
227233
timer.unref?.()
228234
pendingErrors.set(timer, event)
@@ -268,7 +274,7 @@ export function beginDevUI(options: DevUISupportOptions & { version?: string, cw
268274
surface.setCapture()
269275
surface.writeRaw(SHOW_CURSOR)
270276
for (const [, event] of unsurfaced) {
271-
surface.writeRaw(`${event.rendered ?? `${styleText(['red', 'bold'], 'ERROR')} ${event.message}`}\n`)
277+
surface.writeRaw(`${renderErrorLine(event)}\n`)
272278
}
273279
surface.close({ keep: teardownOptions.keep })
274280
consola.removeReporter(reporter)

packages/nuxt-cli/src/dev/tui/surface.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,9 +156,15 @@ export class PanelSurface {
156156
return
157157
}
158158
this.#capture = undefined
159+
const held = this.#held
159160
this.#held = undefined
160161
clearTimeout(this.#repaintTimer)
161162
this.#erase()
163+
// Whatever a view was holding is the session's last word on what happened.
164+
for (const chunk of held ?? []) {
165+
this.#observe(chunk)
166+
this.#raw(chunk)
167+
}
162168
if (options.keep && this.#lines.length) {
163169
this.#raw(`${this.#lines.join('\n')}\n`)
164170
}

packages/nuxt-cli/src/dev/tui/width.ts

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,13 @@ export function visibleWidth(text: string): number {
1111
return stripAnsi(text).length
1212
}
1313

14+
/** Ends whatever the cut interrupted, so nothing leaks onto the next line. */
15+
const LINK_TERMINATOR = '\u001B]8;;\u0007'
16+
1417
/**
15-
* Cut `text` to `columns`, ignoring escape sequences when measuring and
16-
* carrying them across the cut so a truncated line cannot leak its styling
17-
* onto the rest of the screen.
18+
* Cut `text` to `columns`, ignoring escape sequences when measuring and closing
19+
* whatever they opened, so a truncated line cannot leak its styling or turn the
20+
* rest of the screen into a hyperlink.
1821
*/
1922
export function truncate(text: string, columns: number): string {
2023
if (columns <= 0) {
@@ -28,17 +31,23 @@ export function truncate(text: string, columns: number): string {
2831
let visible = 0
2932
let index = 0
3033
let styled = false
34+
let linked = false
3135
ANSI_RE.lastIndex = 0
3236
while (index < text.length && visible < limit) {
3337
ANSI_RE.lastIndex = index
3438
const match = ANSI_RE.exec(text)
3539
if (match?.index === index) {
36-
styled = true
40+
if (match[0].startsWith('\u001B]8;')) {
41+
linked = match[0] !== LINK_TERMINATOR
42+
}
43+
else {
44+
styled = true
45+
}
3746
index += match[0].length
3847
continue
3948
}
4049
index++
4150
visible++
4251
}
43-
return `${text.slice(0, index)}\u2026${styled ? '\u001B[0m' : ''}`
52+
return `${text.slice(0, index)}\u2026${linked ? LINK_TERMINATOR : ''}${styled ? '\u001B[0m' : ''}`
4453
}

packages/nuxt-cli/src/dev/utils.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,8 @@ function resolveRequestContextPlugin(): string | undefined {
5050
try {
5151
return fileURLToPath(import.meta.resolve('@nuxt/cli/runtime/dev-request-context'))
5252
}
53-
catch {
53+
catch (error) {
54+
debug('Could not resolve the request context plugin; app logs will not be attributed:', error)
5455
return undefined
5556
}
5657
}
@@ -591,9 +592,11 @@ export class NuxtDevServer extends EventEmitter<DevServerEventMap> {
591592
...requestContextPlugin
592593
? {
593594
hooks: {
595+
...this.options.overrides.hooks,
594596
'nitro:config': (nitro) => {
595597
nitro.plugins ||= []
596598
nitro.plugins.push(requestContextPlugin)
599+
return this.options.overrides.hooks?.['nitro:config']?.(nitro)
597600
},
598601
} satisfies NuxtConfig['hooks'],
599602
}

packages/nuxt-cli/src/utils/startup-clock.ts

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55
* than how long a question sat on screen.
66
*/
77

8-
let excludedMs = 0
8+
/** Closed pauses, as `[start, end]`. Only a handful happen in a session. */
9+
const pauses: Array<[number, number]> = []
910
let pausedAt: number | undefined
1011
let depth = 0
1112

@@ -17,7 +18,7 @@ function pauseStartupClock(): void {
1718

1819
function resumeStartupClock(): void {
1920
if (depth > 0 && --depth === 0 && pausedAt !== undefined) {
20-
excludedMs += Date.now() - pausedAt
21+
pauses.push([pausedAt, Date.now()])
2122
pausedAt = undefined
2223
}
2324
}
@@ -33,8 +34,16 @@ export async function withStartupClockPaused<T>(work: () => Promise<T>): Promise
3334
}
3435
}
3536

36-
/** Milliseconds since `since`, not counting paused stretches. */
37+
/**
38+
* Milliseconds since `since`, not counting paused stretches.
39+
*
40+
* Only the part of a pause that falls after `since` is subtracted, so a
41+
* baseline taken after a prompt is not charged for the time it took to answer.
42+
*/
3743
export function startupElapsedMs(since: number): number {
38-
const open = pausedAt === undefined ? 0 : Date.now() - pausedAt
39-
return Math.max(0, Date.now() - since - excludedMs - open)
44+
const now = Date.now()
45+
const open = pausedAt === undefined ? [] : [[pausedAt, now] as [number, number]]
46+
const excluded = [...pauses, ...open]
47+
.reduce((total, [start, end]) => total + Math.max(0, Math.min(end, now) - Math.max(start, since)), 0)
48+
return Math.max(0, now - since - excluded)
4049
}

0 commit comments

Comments
 (0)