From 5e92da267fcac1ec1a10107d7805bf98a0af73b3 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Mon, 26 Oct 2026 22:26:33 +0000 Subject: [PATCH 1/3] perf(typecheck): generate types in a short-lived child process --- packages/nuxt-cli/src/commands/typecheck.ts | 37 ++++------- packages/nuxt-cli/src/utils/write-types.ts | 57 +++++++++++++++++ packages/nuxt-cli/src/write-types.ts | 17 ++++++ .../test/unit/commands/typecheck.spec.ts | 61 ++++++++++++++++++- packages/nuxt-cli/tsdown.config.ts | 2 +- 5 files changed, 144 insertions(+), 30 deletions(-) create mode 100644 packages/nuxt-cli/src/utils/write-types.ts create mode 100644 packages/nuxt-cli/src/write-types.ts diff --git a/packages/nuxt-cli/src/commands/typecheck.ts b/packages/nuxt-cli/src/commands/typecheck.ts index 535ed2906..ab4171afb 100644 --- a/packages/nuxt-cli/src/commands/typecheck.ts +++ b/packages/nuxt-cli/src/commands/typecheck.ts @@ -13,13 +13,12 @@ import { readPackageJSON, readTSConfig } from 'pkg-types' import { hasTTY } from 'std-env' import { x } from 'tinyexec' -import { resolveDotenvFileNames } from '../utils/args' import { formatDuration } from '../utils/formatting' -import { loadKit } from '../utils/kit' import { logger } from '../utils/logger' import { resolveRootDir } from '../utils/paths' import { withNodePath } from '../utils/resolve-nuxt' import { createSpinner } from '../utils/spinner' +import { writeTypes, writeTypesInChild } from '../utils/write-types' import { dotEnvArgs, extendsArgs, logLevelArgs, rootDirArgs } from './_shared' type TypeChecker = 'vue-tsc' | 'golar' @@ -147,11 +146,18 @@ export default defineCommand({ return } + // Overrides may not be serialisable, so they are applied in this process. + const hasOverrides = Object.keys(ctx.data?.overrides ?? {}).length > 0 const [tsConfig] = await Promise.all([ readTSConfig(cwd).catch(() => ({} as TSConfig)), - writeTypes(cwd, ctx.args.dotenv, ctx.args.logLevel as 'silent' | 'info' | 'verbose', { - ...ctx.data?.overrides, - ...(ctx.args.extends.length > 0 && { extends: ctx.args.extends }), + (hasOverrides ? writeTypes : writeTypesInChild)({ + cwd, + dotenv: ctx.args.dotenv, + logLevel: ctx.args.logLevel as 'silent' | 'info' | 'verbose', + overrides: { + ...ctx.data?.overrides, + ...(ctx.args.extends.length > 0 && { extends: ctx.args.extends }), + }, }), ]) @@ -370,24 +376,3 @@ async function installMissingPackages(options: { logger.info(`You can install ${plural ? 'them' : 'it'} manually with:\n\n ${styleText('bold', installCommand)}\n`) return false } - -async function writeTypes(cwd: string, dotenv?: string[], logLevel?: 'silent' | 'info' | 'verbose', overrides?: Record) { - const { loadNuxt, buildNuxt, writeTypes } = await loadKit(cwd) - const nuxt = await loadNuxt({ - cwd, - dotenv: { cwd, fileName: resolveDotenvFileNames(dotenv) }, - overrides: { - _prepare: true, - logLevel, - ...overrides, - }, - }) - - try { - await writeTypes(nuxt) - await buildNuxt(nuxt) - } - finally { - await nuxt.close() - } -} diff --git a/packages/nuxt-cli/src/utils/write-types.ts b/packages/nuxt-cli/src/utils/write-types.ts new file mode 100644 index 000000000..4cb3273d2 --- /dev/null +++ b/packages/nuxt-cli/src/utils/write-types.ts @@ -0,0 +1,57 @@ +import { spawn } from 'node:child_process' +import { once } from 'node:events' +import process from 'node:process' +import { fileURLToPath } from 'node:url' + +import { resolveDotenvFileNames } from './args' +import { ActionableError } from './errors' +import { loadKit } from './kit' + +export interface WriteTypesOptions { + cwd: string + dotenv?: string[] + logLevel?: 'silent' | 'info' | 'verbose' + overrides?: Record +} + +/** Load Nuxt in this process, write its types and build its templates. */ +export async function writeTypes(options: WriteTypesOptions): Promise { + const { cwd, dotenv, logLevel, overrides } = options + const { loadNuxt, buildNuxt, writeTypes } = await loadKit(cwd) + const nuxt = await loadNuxt({ + cwd, + dotenv: { cwd, fileName: resolveDotenvFileNames(dotenv) }, + overrides: { + _prepare: true, + logLevel, + ...overrides, + }, + }) + + try { + await writeTypes(nuxt) + await buildNuxt(nuxt) + } + finally { + await nuxt.close() + } +} + +/** + * Run {@link writeTypes} in a short-lived Node.js process, so this one does not + * keep Nuxt in memory afterwards. `options` must be JSON-serialisable. + */ +export async function writeTypesInChild(options: WriteTypesOptions): Promise { + const entry = fileURLToPath(new URL('./write-types.mjs', import.meta.url)) + const child = spawn(process.execPath, [entry, JSON.stringify(options)], { + cwd: options.cwd, + stdio: 'inherit', + }) + const [code, signal] = await once(child, 'exit') as [number | null, NodeJS.Signals | null] + if (signal) { + throw new ActionableError(`Generating types was terminated by ${signal}.`) + } + if (code !== 0) { + throw new ActionableError(`Generating types failed with exit code ${code}. Fix the error above and try again.`) + } +} diff --git a/packages/nuxt-cli/src/write-types.ts b/packages/nuxt-cli/src/write-types.ts new file mode 100644 index 000000000..d63d83d85 --- /dev/null +++ b/packages/nuxt-cli/src/write-types.ts @@ -0,0 +1,17 @@ +import type { WriteTypesOptions } from './utils/write-types' + +import process from 'node:process' + +import { setupGlobalConsole } from './utils/console' +import { asActionableError } from './utils/errors' +import { writeTypes } from './utils/write-types' + +setupGlobalConsole() + +writeTypes(JSON.parse(process.argv[2]!) as WriteTypesOptions) + .catch((error) => { + console.error(asActionableError(error), '\n') + process.exitCode = 1 + }) + // Handles left open after `nuxt.close()` must not keep the parent waiting. + .finally(() => process.exit()) diff --git a/packages/nuxt-cli/test/unit/commands/typecheck.spec.ts b/packages/nuxt-cli/test/unit/commands/typecheck.spec.ts index 40110d20c..dead4a332 100644 --- a/packages/nuxt-cli/test/unit/commands/typecheck.spec.ts +++ b/packages/nuxt-cli/test/unit/commands/typecheck.spec.ts @@ -1,3 +1,4 @@ +import { EventEmitter } from 'node:events' import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import process from 'node:process' @@ -5,16 +6,22 @@ import process from 'node:process' import { join } from 'pathe' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { runInstall, answers, resolveModulePath, tinyexec, writeTypes } = vi.hoisted(() => ({ +const { runInstall, answers, resolveModulePath, spawn, tinyexec, writeTypes } = vi.hoisted(() => ({ runInstall: vi.fn((_options: Record) => Promise.resolve({ success: true, output: '', command: '', ignoredBuilds: [] as string[] })), answers: { select: [] as unknown[], confirm: [] as unknown[] }, resolveModulePath: vi.fn(), + spawn: vi.fn(), tinyexec: vi.fn(() => Promise.resolve({ exitCode: 0, stdout: '', stderr: '' })), writeTypes: vi.fn(() => Promise.resolve()), })) vi.mock('tinyexec', () => ({ x: tinyexec })) +vi.mock('node:child_process', async importOriginal => ({ + ...await importOriginal(), + spawn, +})) + vi.mock('exsolve', async importOriginal => ({ ...await importOriginal(), resolveModulePath, @@ -71,10 +78,19 @@ function installed(...names: string[]): void { }) } -async function runTypecheck(argv: string[] = []): Promise<{ output: string, exitCode: number | undefined }> { +/** Make the type generation process exit with the given code or signal. */ +function typegenExits(code: number | null, signal: NodeJS.Signals | null = null): void { + spawn.mockImplementation(() => { + const child = new EventEmitter() + setImmediate(() => child.emit('exit', code, signal)) + return child + }) +} + +async function runTypecheck(argv: string[] = [], data?: { overrides?: Record }): Promise<{ output: string, exitCode: number | undefined }> { process.exitCode = undefined const renderer = await render(async () => { - await runCommandDef(typecheck, [`--cwd=${cwd}`, ...argv]) + await runCommandDef(typecheck, [`--cwd=${cwd}`, ...argv], data) }) const exitCode = process.exitCode as number | undefined process.exitCode = 0 @@ -87,6 +103,7 @@ beforeEach(async () => { answers.confirm.length = 0 vi.clearAllMocks() tinyexec.mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' }) + typegenExits(0) runInstall.mockImplementation(() => Promise.resolve({ success: true, output: '', command: '', ignoredBuilds: [] as string[] })) installed('typescript', 'vue-tsc/bin/vue-tsc.js') await writeFile(join(cwd, 'package.json'), JSON.stringify({ name: 'app', type: 'module' })) @@ -223,3 +240,41 @@ describe('typecheck results', () => { expect(exitCode).toBe(2) }) }) + +describe('typecheck type generation', () => { + it('should generate types in a separate node process', async () => { + await runTypecheck(['--dotenv=.env.test', '--logLevel=silent', '--extends=./layer']) + + expect(spawn).toHaveBeenCalledWith(process.execPath, [expect.stringMatching(/write-types\.mjs$/), expect.any(String)], expect.objectContaining({ cwd, stdio: 'inherit' })) + expect(JSON.parse(spawn.mock.calls[0]![1][1])).toEqual({ + cwd, + dotenv: ['.env.test'], + logLevel: 'silent', + overrides: { extends: ['./layer'] }, + }) + expect(writeTypes).not.toHaveBeenCalled() + expect(tinyexec).toHaveBeenCalledTimes(1) + }) + + it('should fail without type-checking when generating types fails', async () => { + typegenExits(1) + + await expect(runTypecheck()).rejects.toThrow('Generating types failed with exit code 1') + expect(tinyexec).not.toHaveBeenCalled() + }) + + it('should report the signal that terminated type generation', async () => { + typegenExits(null, 'SIGKILL') + + await expect(runTypecheck()).rejects.toThrow('Generating types was terminated by SIGKILL') + expect(tinyexec).not.toHaveBeenCalled() + }) + + it('should generate types in this process when called with overrides', async () => { + await runTypecheck([], { overrides: { hooks: { ready: () => {} } } }) + + expect(spawn).not.toHaveBeenCalled() + expect(writeTypes).toHaveBeenCalledTimes(1) + expect(tinyexec).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/nuxt-cli/tsdown.config.ts b/packages/nuxt-cli/tsdown.config.ts index 1b04d77c5..3f47d17a2 100644 --- a/packages/nuxt-cli/tsdown.config.ts +++ b/packages/nuxt-cli/tsdown.config.ts @@ -10,7 +10,7 @@ export const packaging: PackagingContract = { } export default defineCliConfig({ - entry: ['src/index.ts', 'src/boot.ts', 'src/dev/index.ts'], + entry: ['src/index.ts', 'src/boot.ts', 'src/dev/index.ts', 'src/write-types.ts'], deps: { onlyBundle: ['@bomb.sh/tab', 'citty', 'h3', '@speed-highlight/core'], neverBundle: PARSER_PACKAGES }, ...packaging, }) From e514c37ba7293b4790f98a36cbe7646adc78e2c5 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Mon, 26 Oct 2026 23:07:23 +0000 Subject: [PATCH 2/3] perf: lazy-load type generation helpers --- packages/nuxt-cli/src/commands/typecheck.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/nuxt-cli/src/commands/typecheck.ts b/packages/nuxt-cli/src/commands/typecheck.ts index ab4171afb..084879079 100644 --- a/packages/nuxt-cli/src/commands/typecheck.ts +++ b/packages/nuxt-cli/src/commands/typecheck.ts @@ -18,7 +18,6 @@ import { logger } from '../utils/logger' import { resolveRootDir } from '../utils/paths' import { withNodePath } from '../utils/resolve-nuxt' import { createSpinner } from '../utils/spinner' -import { writeTypes, writeTypesInChild } from '../utils/write-types' import { dotEnvArgs, extendsArgs, logLevelArgs, rootDirArgs } from './_shared' type TypeChecker = 'vue-tsc' | 'golar' @@ -148,6 +147,7 @@ export default defineCommand({ // Overrides may not be serialisable, so they are applied in this process. const hasOverrides = Object.keys(ctx.data?.overrides ?? {}).length > 0 + const { writeTypes, writeTypesInChild } = await import('../utils/write-types') const [tsConfig] = await Promise.all([ readTSConfig(cwd).catch(() => ({} as TSConfig)), (hasOverrides ? writeTypes : writeTypesInChild)({ From 44ab1de107ef91b6ecb65427deafc98138e22b29 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Mon, 26 Oct 2026 23:14:36 +0000 Subject: [PATCH 3/3] fix: flush output before type generation exits --- packages/nuxt-cli/src/write-types.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/nuxt-cli/src/write-types.ts b/packages/nuxt-cli/src/write-types.ts index d63d83d85..7aa19527c 100644 --- a/packages/nuxt-cli/src/write-types.ts +++ b/packages/nuxt-cli/src/write-types.ts @@ -14,4 +14,7 @@ writeTypes(JSON.parse(process.argv[2]!) as WriteTypesOptions) process.exitCode = 1 }) // Handles left open after `nuxt.close()` must not keep the parent waiting. - .finally(() => process.exit()) + .finally(async () => { + await Promise.all([process.stdout, process.stderr].map(stream => new Promise(resolve => stream.write('', resolve)))) + process.exit() + })