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
3 changes: 1 addition & 2 deletions packages/nuxi/src/launcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url'
import { resolveModulePath } from 'exsolve'

import { isNuxiCommand } from '../../nuxt-cli/src/commands/_utils'
import { tryResolveNuxt } from '../../nuxt-cli/src/utils/kit'
import { withNodePath } from '../../nuxt-cli/src/utils/paths'
import { tryResolveNuxt, withNodePath } from '../../nuxt-cli/src/utils/resolve-nuxt'

const FLAG_RE = /^-/

Expand Down
7 changes: 7 additions & 0 deletions packages/nuxt-cli/bin/nuxi.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,13 @@ if (
}
}

// Only `dev` has anything to show before the command graph loads.
if (process.argv[2] === 'dev') {
// Without the panel the command still runs, so a failure here is not fatal.
// eslint-disable-next-line antfu/no-top-level-await
await import('../dist/boot.mjs').then(({ bootDevUI }) => bootDevUI()).catch(() => {})
}

// eslint-disable-next-line antfu/no-top-level-await
const { runMain } = await import('../dist/index.mjs')

Expand Down
55 changes: 55 additions & 0 deletions packages/nuxt-cli/src/boot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import process from 'node:process'

/** Flags that mean this is not going to be a panel session. */
const OPT_OUT = /^(?:--help|-h|--version|-v|--no-tui|--tui|--inspect|--inspect-brk|--profile)(?:=|$)/

/**
* Paint the dev panel and bind its shortcuts, before the command graph loads.
*
* The `dev` command joins the same session and the same shortcut context, and
* gives the terminal back if the resolved arguments refuse the panel.
*/
export async function bootDevUI(): Promise<void> {
const argv = process.argv.slice(2)
if (argv[0] !== 'dev' || argv.some(arg => OPT_OUT.test(arg))) {
return
}
// Cheap first pass at what `resolveDevUISupport` decides properly.
if (!process.stdout.isTTY || !process.stdin.isTTY) {
return
}
const options = { cwd: resolveCwd(argv), startTime: globalThis.__nuxt_cli__?.startTime }
const { paintFirstFrame } = await import('./dev/tui/first-frame')
const start = paintFirstFrame(options)
if (!start) {
return
}
try {
const { devShortcutContext } = await import('./dev/shortcut-context')
const { setupDevUI } = await import('./dev/tui/controller')
await setupDevUI(devShortcutContext().context, { ...options, start })
}
catch (error) {
// The frame is on screen with nothing behind it, so give the terminal back
// before the failure travels on.
start.surface.close()
throw error
}

// Loading the command graph blocks the loop, so the key reader goes first.
await new Promise(resolve => setImmediate(resolve))
}

/** Where to read the project version from: `--cwd`, or `dev`'s `ROOTDIR`. */
function resolveCwd(argv: string[]): string | undefined {
for (let index = 1; index < argv.length; index++) {
const arg = argv[index]!
if (arg.startsWith('--cwd=')) {
return arg.slice('--cwd='.length)
}
if (arg === '--cwd') {
return argv[index + 1]
}
}
return argv[1] && !argv[1].startsWith('-') ? argv[1] : undefined
}
33 changes: 25 additions & 8 deletions packages/nuxt-cli/src/commands/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,16 @@ import { defineCommand } from 'citty'

import { isBun, isTest } from 'std-env'
import { satisfies } from 'verkit'
import { initialize } from '../dev'

import { closeInspector, openInspector, resolveInspectOptions } from '../dev/inspect'
import { isReusePortSupported, parsePort } from '../dev/listen'
import { ForkPool } from '../dev/pool'
import { preflight } from '../dev/preflight'
import { formatRestartReason } from '../dev/reason'
import { deferShortcutContext } from '../dev/shortcut-context'
import { devShortcutContext } from '../dev/shortcut-context'
import { SUPERVISOR_SHUTDOWN_TIMEOUT_MS } from '../dev/shutdown'
import { formatTakeoverRefusal, takeOverDevServer } from '../dev/takeover'
import { beginDevUI, setupDevUI } from '../dev/tui/controller'
import { beginDevUI, setupDevUI, teardownDevUI } from '../dev/tui/controller'
import { replaceCwdArg } from '../utils/args'
import { resolveLockDir } from '../utils/dev-server'
import { summariseActiveResources } from '../utils/hang'
Expand Down Expand Up @@ -177,20 +176,21 @@ const command = defineCommand({
},
async run(ctx) {
const requestedCwd = resolveRootDir(ctx.args)
const cwd = await preflight({ cwd: requestedCwd })
const cwd = await beforeServing(() => preflight({ cwd: requestedCwd }))
if (cwd !== requestedCwd) {
replaceCwdArg(ctx.rawArgs, cwd, requestedCwd)
}

const listenOverrides = resolveListenOverrides(ctx.args)

const buildDir = await resolveLockDir(cwd)
const buildDir = await beforeServing(() => resolveLockDir(cwd))

const takeover = await takeOverDevServer(buildDir, {
const takeover = await beforeServing(() => takeOverDevServer(buildDir, {
requestedPort: parsePort(listenOverrides.port),
takeover: ctx.args.takeover,
})
}))
if (takeover.action === 'refused') {
await teardownDevUI()
logger.error(formatTakeoverRefusal(takeover.existing, takeover.reason))
process.exit(1)
}
Expand Down Expand Up @@ -232,10 +232,16 @@ const command = defineCommand({
listenOverrides.showURL = false
}

const { context: shortcutContext, attach: attachServer } = deferShortcutContext({ clearCaches })
const { context: shortcutContext, attach: attachServer, provide } = devShortcutContext()
provide({ clearCaches })
const startingUI = ui ? await setupDevUI(shortcutContext, { ...uiOptions, enabled: true }) : undefined
setupSignalHandlers(() => shortcutContext.close())

// Evaluating the dev server's graph blocks the loop; let the panel answer
// anything already typed first.
await new Promise(resolve => setImmediate(resolve))
const { initialize } = await import('../dev')

const started = await initialize({ cwd, args: ctx.args, handoverFrom: takeover.action === 'taken' ? takeover.pid : undefined }, {
data: ctx.data,
listenOverrides,
Expand Down Expand Up @@ -457,6 +463,17 @@ type ArgsT = Exclude<
undefined | ((...args: unknown[]) => unknown)
>

/** Run work that must succeed before there is a server, freeing the terminal if it does not. */
async function beforeServing<T>(work: () => Promise<T>): Promise<T> {
try {
return await work()
}
catch (error) {
await teardownDevUI()
throw error
}
}

/**
* Shut the dev server down on `SIGINT`/`SIGTERM`.
*
Expand Down
2 changes: 1 addition & 1 deletion packages/nuxt-cli/src/commands/info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,13 @@ import { getBuilder } from '../utils/banner'
import { resolveCatalogEntry } from '../utils/catalog'
import { withDirectStdout } from '../utils/console'
import { formatInfoBox } from '../utils/formatting'
import { tryResolveNuxt } from '../utils/kit'
import { logger } from '../utils/logger'
import { resolveNitroVersion } from '../utils/nitro'
import { getNuxtConfig } from '../utils/nuxt-config'
import { readDependencyPackageJson } from '../utils/package-json'
import { getPackageManagerVersion } from '../utils/packageManagers'
import { resolveRootDir } from '../utils/paths'
import { tryResolveNuxt } from '../utils/resolve-nuxt'
import { rootDirArgs } from './_shared'

const LEADING_SLASH_RE = /^\//
Expand Down
3 changes: 2 additions & 1 deletion packages/nuxt-cli/src/commands/typecheck.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ import { x } from 'tinyexec'
import { resolveDotenvFileNames } from '../utils/args'
import { loadKit } from '../utils/kit'
import { logger } from '../utils/logger'
import { resolveRootDir, withNodePath } from '../utils/paths'
import { resolveRootDir } from '../utils/paths'
import { withNodePath } from '../utils/resolve-nuxt'
import { dotEnvArgs, extendsArgs, logLevelArgs, rootDirArgs } from './_shared'

type TypeChecker = 'vue-tsc' | 'golar'
Expand Down
4 changes: 2 additions & 2 deletions packages/nuxt-cli/src/dev/binaries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { restoreRawMode, withDirectStdout } from '../utils/console'
import { debug, logger } from '../utils/logger'
import { logNetworkError } from '../utils/network'
import { findInPath } from '../utils/path-env'
import { withStartupClockPaused } from '../utils/startup-clock'
import { withUserAttention } from '../utils/startup-clock'

interface ConsentOptions {
/** Key under `tools` in the user `.nuxtrc` used to persist acceptance. */
Expand Down Expand Up @@ -61,7 +61,7 @@ async function locateTool(name: string, options: { url?: string, archive?: boole
}
// A first-run consent prompt and download can dwarf the server's own
// startup, so neither counts towards the reported time to ready.
return withStartupClockPaused(async () => {
return withUserAttention(async () => {
if (!await confirmToolInstall(options.consent)) {
return undefined
}
Expand Down
2 changes: 1 addition & 1 deletion packages/nuxt-cli/src/dev/loading-template.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { pathToFileURL } from 'node:url'
import { resolveModulePath } from 'exsolve'
import { debug } from '../utils/logger'
import { withNodePath } from '../utils/paths'
import { withNodePath } from '../utils/resolve-nuxt'

export type LoadingTemplate = (data: { loading?: string }) => string

Expand Down
8 changes: 4 additions & 4 deletions packages/nuxt-cli/src/dev/preflight.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,11 @@ import { dirname, join } from 'pathe'

import { restoreRawMode, withDirectStdout } from '../utils/console'
import { ActionableError } from '../utils/errors'
import { tryResolveNuxt } from '../utils/kit'
import { debug, logger } from '../utils/logger'
import { CONFIG_EXTENSIONS } from '../utils/nuxt-config'
import { relativeTo } from '../utils/paths'
import { withStartupClockPaused } from '../utils/startup-clock'
import { tryResolveNuxt } from '../utils/resolve-nuxt'
import { withUserAttention } from '../utils/startup-clock'
import { isInteractive } from '../utils/stdout'

const NUXT_PACKAGES = ['nuxt', 'nuxt-nightly']
Expand Down Expand Up @@ -143,7 +143,7 @@ async function resolveProjectDirectory(cwd: string, interactive: boolean): Promi

if (interactive) {
logger.warn(`${styleText('cyan', cwd)} is not a Nuxt project, but ${styleText('cyan', location.ancestor)} is.`)
const answer = await withStartupClockPaused(() => confirm({ message: `Run there instead?`, initialValue: true }))
const answer = await withUserAttention(() => confirm({ message: `Run there instead?`, initialValue: true }))
restoreRawMode()

if (!isCancel(answer) && answer) {
Expand Down Expand Up @@ -218,7 +218,7 @@ async function checkDependencies(cwd: string, interactive: boolean): Promise<voi
].join('\n'))
}

await withStartupClockPaused(() => offerInstall(cwd, interactive))
await withUserAttention(() => offerInstall(cwd, interactive))
}

/**
Expand Down
17 changes: 16 additions & 1 deletion packages/nuxt-cli/src/dev/shortcut-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ interface ShortcutServer {
export interface DeferredShortcutContext {
context: ShortcutContext
attach: (server: ShortcutServer) => void
/** What the dev server cannot supply, such as the caches a restart clears. */
provide: (options: Pick<ShortcutContext, 'clearCaches'>) => void
}

/**
Expand All @@ -32,11 +34,14 @@ export interface DeferredShortcutContext {
export function deferShortcutContext(options: Pick<ShortcutContext, 'clearCaches'> = {}): DeferredShortcutContext {
let server: ShortcutServer | undefined
let closing: Promise<void> | undefined
let clearCaches = options.clearCaches
const pendingReady: Array<(address: string) => void> = []

return {
context: {
clearCaches: options.clearCaches,
get clearCaches() {
return clearCaches
},
get listener() {
return server?.listener
},
Expand Down Expand Up @@ -64,5 +69,15 @@ export function deferShortcutContext(options: Pick<ShortcutContext, 'clearCaches
started.onReady(callback)
}
},
provide: (next) => {
clearCaches = next.clearCaches ?? clearCaches
},
}
}

let shared: DeferredShortcutContext | undefined

/** The context this process's dev shortcuts act through, shared by the entry and the command. */
export function devShortcutContext(): DeferredShortcutContext {
return (shared ??= deferShortcutContext())
}
6 changes: 3 additions & 3 deletions packages/nuxt-cli/src/dev/takeover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { isCI } from 'std-env'
import { restoreRawMode, withDirectStdout } from '../utils/console'
import { clearStaleLock, clearTakeover, isLockEnabled, isProcessAlive, markTakenOver, readLock } from '../utils/lockfile'
import { logger } from '../utils/logger'
import { withStartupClockPaused } from '../utils/startup-clock'
import { withUserAttention } from '../utils/startup-clock'
import { isInteractiveSession } from '../utils/stdout'
import { DEV_SHUTDOWN_TIMEOUT_MS } from './shutdown'

Expand Down Expand Up @@ -127,7 +127,7 @@ async function resolveTakeover(buildDir: string, options: TakeoverOptions): Prom
const prompt = options.prompt ?? promptForTakeover
// Nothing ends an interactive session on a bare enter, so the default
// depends on whether a person is watching the other server.
const choice = await withStartupClockPaused(() => prompt(existing, existing.interactive ? 'abort' : 'takeover'))
const choice = await withUserAttention(() => prompt(existing, existing.interactive ? 'abort' : 'takeover'))
if (choice === 'abort') {
return { action: 'refused', existing, reason: 'declined' }
}
Expand All @@ -138,7 +138,7 @@ async function resolveTakeover(buildDir: string, options: TakeoverOptions): Prom
}
}

return withStartupClockPaused(() => performTakeover(buildDir, existing, options.timeouts))
return withUserAttention(() => performTakeover(buildDir, existing, options.timeouts))
}

async function performTakeover(buildDir: string, existing: LockInfo, timeouts: TakeoverOptions['timeouts'] = {}): Promise<TakeoverResult> {
Expand Down
6 changes: 6 additions & 0 deletions packages/nuxt-cli/src/dev/tui/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,12 @@ export async function beginDevUI(options: DevUIOptions = {}): Promise<DevUISessi
return beginDevUI(options)
}

/** Give the terminal back, so something else can report on a clean screen. */
export async function teardownDevUI(): Promise<void> {
const { teardownDevUI } = await import('./session')
teardownDevUI()
}

/** The interactive controller, or the line-based shortcuts and a no-op. */
export async function setupDevUI(context: ShortcutContext, options: DevUIOptions = {}): Promise<DevUIController> {
if (options.enabled === false) {
Expand Down
65 changes: 65 additions & 0 deletions packages/nuxt-cli/src/dev/tui/first-frame.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import type { PanelState } from './panel'
import type { DevUISupportOptions } from './support'

import process from 'node:process'

import { getPkgVersion } from '../../utils/pkg'
import { resolveBackground } from '../../utils/terminal-theme'
import { DEFAULT_HINTS, renderPanel } from './panel'
import { resolveDevUISupport, supportsUnicode } from './support'
import { PanelSurface } from './surface'

export interface PanelStart {
surface: PanelSurface
state: PanelState
}

export interface PanelStartOptions extends DevUISupportOptions {
version?: string
cwd?: string
startTime?: number
}

/** The panel as it looks before anything has been loaded. */
export function createPanelState(options: PanelStartOptions = {}): PanelState {
const cwd = options.cwd || process.cwd()
return {
status: 'starting',
version: options.version || getPkgVersion(cwd, 'nuxt') || getPkgVersion(cwd, 'nuxt-nightly') || undefined,
warnings: 0,
errors: 0,
ascii: !supportsUnicode(),
background: resolveBackground(),
loadStartedAt: options.startTime ?? Date.now(),
elapsedMs: 0,
progress: 0,
hints: DEFAULT_HINTS,
hintsDimmed: true,
}
}

export function renderPanelState(surface: PanelSurface, state: PanelState): void {
surface.render(panelLines(state))
}

function panelLines(state: PanelState): string[] {
return renderPanel(state, process.stdout.columns || 80, process.stdout.rows || 24)
}

/**
* Put the panel on screen before the session that drives it exists.
*
* Log capture, the event log and the progress feed all cost more to load than
* the frame, and none of them has anything to show yet.
*/
export function paintFirstFrame(options: PanelStartOptions = {}): PanelStart | undefined {
if (!resolveDevUISupport(options).enabled) {
return undefined
}
const state = createPanelState(options)
const surface = new PanelSurface()
// Until the session takes over, the frame repaints itself.
surface.onResize(() => renderPanelState(surface, state))
surface.renderAtBottom(panelLines(state))
return { surface, state }
}
Loading
Loading