Skip to content
Draft
Show file tree
Hide file tree
Changes from 7 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
20 changes: 19 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,24 @@ export default defineCommand({
...ctx.data?.overrides,
},
})
await clearBuildDir(nuxt.options.buildDir)

// Only wipe the build dir when no dev server or build owns it. `clearBuildDir`
// removes every generated artifact (drizzle schema, mdc, cf-jobs registry, …)
// before `buildNuxt` rewrites them; a concurrent dev server's watcher fires a
// reload into that gap and resolves aliases like `#schema/<n>` against a
// momentarily-absent file → ENOENT. Wiping under a running build is just as
// destructive. `buildNuxt` regenerates every template in place regardless
// (content-diffed, non-destructive), so reuse is safe and still hands
// `db:generate` fresh artifacts. Stop the owning process if a clean wipe is
// genuinely needed — the two can't coexist.
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
84 changes: 79 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,42 @@ 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; with
// peer dev servers sharing a buildDir, any one of them keeps `.nuxt` fresh.
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
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
5 changes: 4 additions & 1 deletion packages/nuxi/src/utils/fs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,10 @@ export async function clearDir(path: string, exclude?: string[]) {
}

export function clearBuildDir(path: string) {
return clearDir(path, ['cache', 'analyze', 'nuxt.json', 'nuxt.lock'])
// Keep `locks/` so a wipe never deletes a presence marker a peer dev/build
// process just wrote (see utils/lockfile). `nuxt.lock` is the pre-`locks/`
// marker name, retained so an in-flight upgrade doesn't strand an old server.
return clearDir(path, ['cache', 'analyze', 'nuxt.json', 'nuxt.lock', 'locks'])
}

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