Skip to content
Draft
Show file tree
Hide file tree
Changes from 4 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
82 changes: 77 additions & 5 deletions packages/nuxi/src/commands/typecheck.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
import { existsSync } from 'node:fs'
import process from 'node:process'

import { cancel, confirm, isCancel, spinner } from '@clack/prompts'
import { defineCommand } from 'citty'
import { colors } from 'consola/utils'
import { resolveModulePath } from 'exsolve'
import { addDevDependency, detectPackageManager } from 'nypm'
import { resolve } from 'pathe'
import { join, resolve } from 'pathe'
import { readTSConfig } from 'pkg-types'
import { hasTTY } from 'std-env'
import { x } from 'tinyexec'

import { loadKit } from '../utils/kit'
import { readActiveLock } from '../utils/lockfile'
import { logger } from '../utils/logger'
import { cwdArgs, dotEnvArgs, extendsArgs, legacyRootDirArgs, logLevelArgs } from './_shared'

Expand Down Expand Up @@ -42,19 +44,55 @@ export default defineCommand({
...dotEnvArgs,
...extendsArgs,
...legacyRootDirArgs,
prepare: {
type: 'boolean',
description: 'Generate Nuxt types before checking. Defaults to auto: skipped when a dev server is already running for this project. Use --no-prepare to force-reuse, --prepare to always prepare.',
},
},
async run(ctx) {
process.env.NODE_ENV = process.env.NODE_ENV || 'production'

const cwd = resolve(ctx.args.cwd || ctx.args.rootDir)
// Assume the default buildDir. Resolving a custom one means loading config,
// the cost we're trying to skip, so those fall through to preparing.
const buildDir = join(cwd, '.nuxt')

const decision = resolvePrepareDecision(buildDir, {
prepare: ctx.args.prepare as boolean | undefined,
extends: ctx.args.extends,
})

if (!decision.prepare && !existsSync(join(buildDir, 'tsconfig.json'))) {
logger.error(
`Cannot type check without prepared types: no \`${join(buildDir, 'tsconfig.json')}\` found. Run \`nuxt prepare\` or start the dev server first, or drop \`--no-prepare\`.`,
)
process.exitCode = 1
return
}

if (!decision.prepare && ctx.args.extends) {
logger.warn('`--extends` is ignored when prepare is skipped.')
}

if (!decision.prepare && hasTTY) {
logger.info(
decision.reusingDevPid
? `Reusing types from the running dev server (PID ${decision.reusingDevPid}); skipping prepare.`
: 'Skipping prepare; type checking against the existing `.nuxt`.',
)
}

const preparePromise: Promise<void> = decision.prepare
? writeTypes(cwd, ctx.args.dotenv, ctx.args.logLevel as 'silent' | 'info' | 'verbose', {
...ctx.data?.overrides,
...(ctx.args.extends && { extends: ctx.args.extends }),
})
: Promise.resolve()

const [supportsProjects, vueTsc] = await Promise.all([
readTSConfig(cwd).then(r => !!(r.references?.length)),
ensureVueTsc(cwd, resolveDeps()),
writeTypes(cwd, ctx.args.dotenv, ctx.args.logLevel as 'silent' | 'info' | 'verbose', {
...ctx.data?.overrides,
...(ctx.args.extends && { extends: ctx.args.extends }),
}),
preparePromise,
])

if (!vueTsc) {
Expand Down Expand Up @@ -82,6 +120,40 @@ export default defineCommand({
},
})

export interface PrepareDecision {
prepare: boolean
/** PID of the live dev server we are reusing types from, when skipping. */
reusingDevPid?: number
}

/**
* Decide whether `typecheck` runs its own prepare. Skips it when a dev server
* owns this buildDir and has signalled `typesReady`, reusing its `.nuxt` rather
* than rebuilding (which would remove `.nuxt/dist` and restart dev). The
* `typesReady` gate avoids checking against mid-rebuild or stale types. Explicit
* `--prepare`/`--no-prepare` win; in auto mode `--extends` forces a prepare.
*/
export function resolvePrepareDecision(
buildDir: string,
opts: { prepare?: boolean, extends?: string },
): PrepareDecision {
if (opts.prepare === true) {
return { prepare: true }
}
if (opts.prepare === false) {
return { prepare: false }
}
if (opts.extends) {
return { prepare: true }
}

const lock = readActiveLock(buildDir)
if (lock?.command === 'dev' && lock.typesReady === true && existsSync(join(buildDir, 'tsconfig.json'))) {
return { prepare: false, reusingDevPid: lock.pid }
}
return { prepare: true }
}

async function ensureVueTsc(cwd: string, deps: Record<DepName, string | undefined>): Promise<string | undefined> {
const missing = (Object.keys(REQUIRED_DEPS) as DepName[]).filter(name => !deps[name])
if (missing.length === 0) {
Expand Down
19 changes: 16 additions & 3 deletions packages/nuxi/src/dev/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,8 +198,8 @@ export class NuxtDevServer extends EventEmitter<DevServerEventMap> {

await this.#loadNuxtInstance()

// Acquire lock before binding a listener so parallel agent invocations
// fail fast without starting a second server (agent-only).
// Acquire the lock before binding a listener so a conflicting build fails
// fast, and so typecheck can detect this server.
this.#acquireDevLock(this.#currentNuxt!.options.buildDir)

if (this.options.showBanner) {
Expand Down Expand Up @@ -345,6 +345,14 @@ export class NuxtDevServer extends EventEmitter<DevServerEventMap> {
throw new Error('Nuxt must be loaded before configuration')
}

// Mark types stale while (re)building so a concurrent typecheck won't reuse
// mid-rebuild output; set ready again after the build below.
updateLock(this.#currentNuxt.options.buildDir, {
command: 'dev',
cwd: this.options.cwd,
typesReady: false,
})

// Connect Vite HMR
if (!process.env.NUXI_DISABLE_VITE_HMR) {
this.#currentNuxt.hooks.hook('vite:extend', ({ config }) => {
Expand Down Expand Up @@ -487,6 +495,8 @@ export class NuxtDevServer extends EventEmitter<DevServerEventMap> {
port: addr.port,
hostname: addr.address,
url: serverUrl,
// Types are built for the current instance; typecheck may reuse them.
typesReady: true,
})

this.emit('ready', serverUrl)
Expand All @@ -506,10 +516,13 @@ export class NuxtDevServer extends EventEmitter<DevServerEventMap> {
}

#acquireDevLock(buildDir: string): void {
// Advertise this dev server so `nuxt typecheck` can detect it and skip its
// prepare. Peer dev servers may run concurrently, so this never refuses
// another dev, though it still refuses a live build.
const lock = acquireLock(buildDir, {
command: 'dev',
cwd: this.options.cwd,
})
}, { enforce: false })
if (lock.existing) {
console.error(formatLockError(lock.existing))
throw new Error(`Another Nuxt ${lock.existing.command} is already running (PID ${lock.existing.pid}).`)
Expand Down
73 changes: 59 additions & 14 deletions packages/nuxi/src/utils/lockfile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,22 @@ import process from 'node:process'
import { join } from 'pathe'
import { isAgent } from 'std-env'

interface LockInfo {
export interface LockInfo {
pid: number
startedAt: number
command: 'dev' | 'build'
cwd: string
port?: number
hostname?: string
url?: string
/** Set once dev has built types; cleared while (re)building. Gates typecheck reuse. */
typesReady?: boolean
/** Identifies one `acquireLock` call so its release only removes its own marker. */
token?: string
}

let acquireCounter = 0

const LOCK_FILENAME = 'nuxt.lock'
// PID recycling safety net. Locks older than this cannot be trusted because a
// recycled PID could match a dead build's record.
Expand Down Expand Up @@ -61,8 +67,8 @@ function isLockActive(info: LockInfo): boolean {
}

/**
* Locking is enabled for agents by default. `NUXT_LOCK=1` forces it on for
* non-agents; `NUXT_IGNORE_LOCK=1` forces it off.
* Default conflict-enforcement policy. On for agents; `NUXT_LOCK=1` forces it on,
* `NUXT_IGNORE_LOCK=1` off. `nuxt build` uses this; `nuxt dev` passes `enforce: false`.
*/
export function isLockEnabled(): boolean {
if (process.env.NUXT_IGNORE_LOCK) {
Expand All @@ -74,27 +80,39 @@ export function isLockEnabled(): boolean {
return isAgent
}

// The marker is written unless explicitly opted out, even when enforcement is off,
// so other commands (e.g. typecheck) can detect a running dev server.
function isLockWriteEnabled(): boolean {
return !process.env.NUXT_IGNORE_LOCK
}

type LockResult
= | { existing?: undefined, release: () => void }
| { existing: LockInfo, release?: undefined }

/**
* Atomically acquire a build/dev lock.
* Returns `{ existing }` if another live process holds the lock, otherwise
* `{ release }` to be invoked on shutdown. No-op when locking is disabled.
* Acquire a build/dev lock. Returns `{ existing }` when a conflicting live lock
* blocks us, otherwise `{ release }` to invoke on shutdown. The marker is always
* written for detection. With `enforce` false (used by `nuxt dev`) a peer dev
* lock is taken over rather than refused, though an active `build` lock is still
* refused. `enforce` defaults to `isLockEnabled()`. No-op when writing is disabled.
*/
export function acquireLock(
buildDir: string,
info: Omit<LockInfo, 'pid' | 'startedAt'>,
opts: { enforce?: boolean } = {},
): LockResult {
if (!isLockEnabled()) {
if (!isLockWriteEnabled()) {
return { release: () => {} }
}

const enforce = opts.enforce ?? isLockEnabled()
const lockPath = join(buildDir, LOCK_FILENAME)
const token = `${process.pid}:${++acquireCounter}`
const fullInfo: LockInfo = {
pid: process.pid,
startedAt: Date.now(),
token,
...info,
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

Expand All @@ -105,12 +123,23 @@ export function acquireLock(
}
catch {}

// Try exclusive-create up to twice: the first attempt may race with a stale
// lock that we then clean up and retry.
if (!enforce) {
// Peer dev servers may share a buildDir, but an active build mutates it and
// must not be clobbered.
const existing = readLockFile(lockPath)
if (existing && isLockActive(existing) && existing.command === 'build') {
return { existing }
}
writeFileSync(lockPath, JSON.stringify(fullInfo, null, 2))
return { release: makeRelease(lockPath, token) }
}

// Enforcing path: try exclusive-create up to twice (the first attempt may
// race with a stale lock we then clean up and retry).
for (let attempt = 0; attempt < 2; attempt++) {
try {
writeFileSync(lockPath, JSON.stringify(fullInfo, null, 2), { flag: 'wx' })
return { release: makeRelease(lockPath) }
return { release: makeRelease(lockPath, token) }
}
catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'EEXIST') {
Expand All @@ -133,6 +162,16 @@ export function acquireLock(
return { release: () => {} }
}

/**
* Read the lock file for a buildDir, returning it only when it belongs to a
* still-running process. Used by other commands (e.g. `typecheck`) to detect a
* live `dev`/`build` and reuse its prepared `.nuxt`.
*/
export function readActiveLock(buildDir: string): LockInfo | undefined {
const info = readLockFile(join(buildDir, LOCK_FILENAME))
return info && isLockActive(info) ? info : undefined
}

/**
* Overwrite an existing lock we already own with updated metadata (e.g. port
* information learned after the listener binds). Callers must hold the lock
Expand All @@ -142,7 +181,7 @@ export function updateLock(
buildDir: string,
info: Omit<LockInfo, 'pid' | 'startedAt'>,
): void {
if (!isLockEnabled()) {
if (!isLockWriteEnabled()) {
return
}
const lockPath = join(buildDir, LOCK_FILENAME)
Expand All @@ -151,18 +190,22 @@ export function updateLock(
if (current && current.pid !== process.pid) {
return
}
// Merge so a partial update (e.g. toggling `typesReady`) keeps existing fields
// like `url`, and the original acquisition's token survives for its release.
const next: LockInfo = {
...current,
...info,
pid: process.pid,
startedAt: current?.startedAt ?? Date.now(),
...info,
token: current?.token,
}
try {
writeFileSync(lockPath, JSON.stringify(next, null, 2))
}
catch {}
}

function makeRelease(lockPath: string): () => void {
function makeRelease(lockPath: string, token: string): () => void {
let released = false

function release(): void {
Expand All @@ -171,8 +214,10 @@ function makeRelease(lockPath: string): () => void {
}
released = true
process.off('exit', release)
// A same-process re-acquire (dev reload) writes a new token, so only remove
// the file if it still carries ours.
const current = readLockFile(lockPath)
if (!current || current.pid === process.pid) {
if (current?.token === token) {
tryUnlink(lockPath)
}
}
Expand Down
Loading
Loading