Skip to content
Draft
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
16 changes: 15 additions & 1 deletion packages/nuxi/src/commands/prepare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { resolve } from 'pathe'

import { clearBuildDir } from '../utils/fs'
import { loadKit } from '../utils/kit'
import { readActiveLock } from '../utils/lockfile'
import { logger } from '../utils/logger'
import { relativeToProcess } from '../utils/paths'
import { cwdArgs, dotEnvArgs, envNameArgs, extendsArgs, legacyRootDirArgs, logLevelArgs } from './_shared'
Expand Down Expand Up @@ -43,7 +44,20 @@ export default defineCommand({
...ctx.data?.overrides,
},
})
await clearBuildDir(nuxt.options.buildDir)

// Only wipe when nothing owns the buildDir. `clearBuildDir` removes the
// generated artifacts a live dev server's watcher reloads against, so it
// would resolve a freshly-deleted file (ENOENT); wiping under a build is
// equally destructive. `buildNuxt` refreshes every template in place anyway,
// so reuse is safe. Stop the owner if a clean wipe is genuinely needed.
const owner = readActiveLock(nuxt.options.buildDir)
if (owner) {
const label = owner.command === 'dev' ? 'dev server' : 'build'
logger.info(`A ${label} (PID ${owner.pid}) owns ${colors.cyan(relativeToProcess(nuxt.options.buildDir))}; refreshing templates in place without clearing.`)
}
else {
await clearBuildDir(nuxt.options.buildDir)
}

await buildNuxt(nuxt)
await writeTypes(nuxt)
Expand Down
83 changes: 78 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 { readActiveLocks } 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 (default buildDir only). 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,41 @@ 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 }
}

// Reuse types from any live dev server that has signalled readiness.
const devLock = readActiveLocks(buildDir).find(l => l.command === 'dev' && l.typesReady === true)
if (devLock && existsSync(join(buildDir, 'tsconfig.json'))) {
return { prepare: false, reusingDevPid: devLock.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
25 changes: 22 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,14 +516,23 @@ 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}).`)
}
// We coexist with peer dev servers rather than refuse them, but they share
// this `.nuxt` — concurrent writes/watches can trigger conflicting reloads.
const peerDev = lock.peers?.find(l => l.command === 'dev')
if (peerDev) {
console.warn(`Another Nuxt dev server is already running (PID ${peerDev.pid}); reusing the same \`.nuxt\` build dir. Run on a separate buildDir to avoid conflicting reloads.`)
}
// Swap atomically: install the new release before freeing the old one so
// we're never unlocked in between.
const previousRelease = this.#lockCleanup
Expand Down
4 changes: 3 additions & 1 deletion packages/nuxi/src/utils/fs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ export async function clearDir(path: string, exclude?: string[]) {
}

export function clearBuildDir(path: string) {
return clearDir(path, ['cache', 'analyze', 'nuxt.json', 'nuxt.lock'])
// Keep `locks/` (and the legacy `nuxt.lock`) so a wipe never deletes a
// presence marker a peer dev/build process holds (see utils/lockfile).
return clearDir(path, ['cache', 'analyze', 'nuxt.json', 'nuxt.lock', 'locks'])
}

export async function rmRecursive(paths: string[]) {
Expand Down
Loading
Loading