diff --git a/docs/typecheck.md b/docs/typecheck.md index 1b0eac252..a59b7c301 100644 --- a/docs/typecheck.md +++ b/docs/typecheck.md @@ -10,7 +10,7 @@ links: ```bash [Terminal] -npx nuxt typecheck [ROOTDIR] [--cwd=] [--logLevel=] [--dotenv=...] [-e, --extends=...] [--checker=] [-b, --build] +npx nuxt typecheck [ROOTDIR] [--cwd=] [--logLevel=] [--dotenv=...] [-e, --extends=...] [--checker=] [-b, --build] [--per-project] ``` @@ -36,6 +36,7 @@ The `typecheck` command runs [`vue-tsc`](https://github.com/vuejs/language-tools | `--checker=` | | Type checker to use | | `-b, --build` | | Type-check in build mode, using TypeScript project references (detected automatically by default) | | `--no-build` | | Type-check without TypeScript project references | +| `--per-project` | | Type-check each project reference in its own process, one after another, to lower peak memory usage | ## Type checkers @@ -46,6 +47,8 @@ The `typecheck` command runs [`vue-tsc`](https://github.com/vuejs/language-tools A solution-style `tsconfig.json` (one that only lists `references`) is type-checked in build mode automatically. `--build` forces it, and `--no-build` turns it off. +Build mode checks every project reference in a single process, which holds on to the memory of each project until the end. On machines with little memory, `--per-project` checks each reference in its own process instead (`vue-tsc -p --noEmit`, or `golar tsc -p --noEmit`), one after another, so peak memory stays at that of the largest project. It keeps going after a failing project, so one run reports every error. Without memory pressure this is usually a little slower, as types shared between projects are loaded once per process. It is meant for Nuxt's generated project configs: if a referenced project has `references` of its own, those are not built first. + ::note This command sets `process.env.NODE_ENV` to `production`. To override, define `NODE_ENV` in a [`.env`](/docs/directory-structure/env) file or as a command-line argument. :: diff --git a/packages/nuxt-cli/src/commands/typecheck.ts b/packages/nuxt-cli/src/commands/typecheck.ts index 535ed2906..1f6a7876f 100644 --- a/packages/nuxt-cli/src/commands/typecheck.ts +++ b/packages/nuxt-cli/src/commands/typecheck.ts @@ -8,8 +8,8 @@ import { styleText } from 'node:util' import { cancel, confirm, isCancel, select } from '@clack/prompts' import { defineCommand } from 'citty' import { resolveModulePath } from 'exsolve' -import { dirname, resolve } from 'pathe' -import { readPackageJSON, readTSConfig } from 'pkg-types' +import { dirname, relative, resolve } from 'pathe' +import { readPackageJSON, readTSConfig, resolveTSConfig } from 'pkg-types' import { hasTTY } from 'std-env' import { x } from 'tinyexec' @@ -45,6 +45,8 @@ interface TypeCheckerBackend { configNote?: string resolve: (cwd: string, options?: { cache?: boolean }) => ResolvedTypeChecker args: (supportsProjects: boolean) => string[] + /** Arguments to type-check a single project, without building its references. */ + projectArgs: (project: string) => string[] ensureConfig?: (cwd: string) => Promise } @@ -81,6 +83,7 @@ const TYPE_CHECKERS: Record = { } }, args: supportsProjects => supportsProjects ? ['-b', '--noEmit'] : ['--noEmit'], + projectArgs: project => ['-p', project, '--noEmit'], }, 'golar': { label: 'Golar', @@ -101,6 +104,7 @@ const TYPE_CHECKERS: Record = { } }, args: supportsProjects => supportsProjects ? ['tsc', '--build', '--noEmit'] : ['tsc', '--noEmit'], + projectArgs: project => ['tsc', '-p', project, '--noEmit'], ensureConfig: ensureGolarConfig, }, } @@ -117,17 +121,21 @@ export default defineCommand({ ...logLevelArgs, ...dotEnvArgs, ...extendsArgs, - checker: { + 'checker': { type: 'string', description: 'Type checker to use', valueHint: CHECKER_PRIORITY.join('|'), }, - build: { + 'build': { type: 'boolean', alias: 'b', description: 'Type-check in build mode, using TypeScript project references (detected automatically by default)', negativeDescription: 'Type-check without TypeScript project references', }, + 'per-project': { + type: 'boolean', + description: 'Type-check each project reference in its own process, one after another, to lower peak memory usage', + }, }, async run(ctx) { process.env.NODE_ENV = process.env.NODE_ENV || 'production' @@ -147,8 +155,8 @@ export default defineCommand({ return } - const [tsConfig] = await Promise.all([ - readTSConfig(cwd).catch(() => ({} as TSConfig)), + const [{ path: tsConfigPath, config: tsConfig }] = await Promise.all([ + readRootTSConfig(cwd), writeTypes(cwd, ctx.args.dotenv, ctx.args.logLevel as 'silent' | 'info' | 'verbose', { ...ctx.data?.overrides, ...(ctx.args.extends.length > 0 && { extends: ctx.args.extends }), @@ -161,6 +169,15 @@ export default defineCommand({ logger.warn(`Your ${styleText('cyan', 'tsconfig.json')} references Nuxt's generated project configs but also includes source files of its own, so type-checking will not run in build mode. Add ${styleText('cyan', '"files": []')} to your ${styleText('cyan', 'tsconfig.json')}, or pass ${styleText('cyan', '--build')} to override.`) } + if (ctx.args['per-project']) { + // Build mode only reads `tsconfig.json` in the root directory, not one found further up. + if (useProjectReferences && tsConfigPath === resolve(cwd, 'tsconfig.json') && supportsProjectReferences(tsConfig)) { + process.exitCode = await typecheckPerProject(cwd, typechecker, resolveProjectReferences(tsConfigPath, tsConfig)) + return + } + logger.warn(`Ignoring ${styleText('cyan', '--per-project')}: it needs build mode and a ${styleText('cyan', 'tsconfig.json')} at the root of your project that only lists project references.`) + } + const start = Date.now() const result = await x(typechecker.bin, TYPE_CHECKERS[typechecker.checker].args(useProjectReferences), { nodeOptions: { stdio: 'inherit', cwd }, @@ -181,6 +198,77 @@ export default defineCommand({ }, }) +async function readRootTSConfig(cwd: string): Promise<{ path?: string, config: TSConfig }> { + const path = await resolveTSConfig(cwd).catch(() => undefined) + const config = path ? await readTSConfig(path).catch(() => ({} as TSConfig)) : {} + return { path, config } +} + +/** + * Resolve the `references` of a tsconfig to config file paths, the way TypeScript does: relative to + * the referencing config, with a path that does not end in `.json` pointing at a directory. + */ +function resolveProjectReferences(tsConfigPath: string, config: TSConfig): string[] { + const root = dirname(tsConfigPath) + return (config.references ?? []) + .filter(reference => reference.path) + .map((reference) => { + const path = resolve(root, reference.path) + return path.endsWith('.json') ? path : resolve(path, 'tsconfig.json') + }) +} + +/** + * Type-check each project in its own checker process, one after another, so peak memory stays at + * that of the largest project rather than all of them. Continues after a failing project so a + * single run reports every error, and resolves to the exit code to use. + */ +async function typecheckPerProject(cwd: string, typechecker: TypeCheckerSetup, projects: string[]): Promise { + const start = Date.now() + let exitCode = 0 + let failed = 0 + + for (const project of projects) { + const name = relative(cwd, project) + const projectStart = Date.now() + const proc = x(typechecker.bin, TYPE_CHECKERS[typechecker.checker].projectArgs(project), { + nodeOptions: { stdio: 'inherit', cwd }, + }) + const result = await proc + const duration = formatDuration(Date.now() - projectStart) + const signal = proc.signalCode ?? null + + if (signal) { + const hint = signal === 'SIGKILL' ? ', which usually means it ran out of memory' : '' + logger.error(`Type-checking ${styleText('cyan', name)} was killed by ${styleText('bold', signal)} after ${styleText('cyan', duration)}${hint}.`) + } + else if (result.exitCode === 0) { + if (hasTTY) { + logger.success(`${styleText('cyan', name)} passed in ${styleText('cyan', duration)}.`) + } + continue + } + else if (hasTTY) { + logger.error(`${styleText('cyan', name)} failed in ${styleText('cyan', duration)}.`) + } + + failed++ + exitCode ||= result.exitCode || 1 + } + + if (hasTTY) { + const duration = styleText('cyan', formatDuration(Date.now() - start)) + if (failed === 0) { + logger.success(`Type check passed in ${duration}.`) + } + else { + logger.error(`Type check failed in ${duration} (${failed} of ${projects.length} projects).`) + } + } + + return exitCode +} + const NUXT_PROJECT_REFERENCE_RE = /(?:^|[/\\])tsconfig\.(?:app|server|shared|node)\.json$/ /** diff --git a/packages/nuxt-cli/test/unit/commands/typecheck.spec.ts b/packages/nuxt-cli/test/unit/commands/typecheck.spec.ts index 40110d20c..49b75f261 100644 --- a/packages/nuxt-cli/test/unit/commands/typecheck.spec.ts +++ b/packages/nuxt-cli/test/unit/commands/typecheck.spec.ts @@ -223,3 +223,124 @@ describe('typecheck results', () => { expect(exitCode).toBe(2) }) }) + +describe('typecheck per project', () => { + const app = './.nuxt/tsconfig.app.json' + + /** A checker process killed by `signal` before it could exit. */ + function killed(signal: string) { + return Object.assign(Promise.resolve({ exitCode: undefined as unknown as number, stdout: '', stderr: '' }), { signalCode: signal }) + } + + beforeEach(async () => { + await writeFile(join(cwd, 'tsconfig.json'), JSON.stringify({ + files: [], + references: [{ path: app }, { path: './.nuxt/tsconfig.server.json' }, { path: 'layers/base' }], + })) + }) + + it('should check each reference in its own process, resolving files and directories', async () => { + const { output, exitCode } = await runTypecheck(['--per-project']) + + expect(tinyexec.mock.calls.map(call => (call as unknown[])[1])).toEqual([ + ['-p', join(cwd, '.nuxt/tsconfig.app.json'), '--noEmit'], + ['-p', join(cwd, '.nuxt/tsconfig.server.json'), '--noEmit'], + ['-p', join(cwd, 'layers/base/tsconfig.json'), '--noEmit'], + ]) + expect(tinyexec).toHaveBeenCalledWith(expect.stringContaining('vue-tsc'), expect.anything(), expect.objectContaining({ + nodeOptions: expect.objectContaining({ cwd }), + })) + expect(output).toContain('.nuxt/tsconfig.app.json passed') + expect(output).toContain('layers/base/tsconfig.json passed') + expect(output).toContain('Type check passed') + expect(exitCode).toBeFalsy() + }) + + it('should not split a tsconfig found above the root directory', async () => { + const rootDir = join(cwd, 'app') + await mkdir(rootDir) + + const { output } = await runTypecheck(['--per-project', '--build', `--cwd=${rootDir}`]) + + expect(output).toContain('Ignoring') + expect(tinyexec).toHaveBeenCalledTimes(1) + expect(tinyexec).toHaveBeenCalledWith(expect.anything(), ['-b', '--noEmit'], expect.anything()) + }) + + it('should split with an explicit `--build`', async () => { + await runTypecheck(['--per-project', '--build']) + + expect(tinyexec).toHaveBeenCalledTimes(3) + expect((tinyexec.mock.calls[0] as unknown[])[1]).toEqual(['-p', join(cwd, '.nuxt/tsconfig.app.json'), '--noEmit']) + }) + + it('should start the next project only after the previous one finished', async () => { + let finish!: () => void + tinyexec.mockReturnValueOnce(new Promise((resolve) => { + finish = () => resolve({ exitCode: 0, stdout: '', stderr: '' }) + })) + + const run = runTypecheck(['--per-project']) + await vi.waitFor(() => expect(tinyexec).toHaveBeenCalledTimes(1)) + await new Promise(resolve => setTimeout(resolve, 20)) + expect(tinyexec).toHaveBeenCalledTimes(1) + + finish() + await run + expect(tinyexec).toHaveBeenCalledTimes(3) + }) + + it('should keep going after a failing project and exit with its code', async () => { + tinyexec + .mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' }) + .mockResolvedValueOnce({ exitCode: 2, stdout: '', stderr: '' }) + .mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' }) + + const { output, exitCode } = await runTypecheck(['--per-project']) + + expect(tinyexec).toHaveBeenCalledTimes(3) + expect(output).toContain('.nuxt/tsconfig.server.json failed') + expect(output).toContain('Type check failed') + expect(output).toContain('1 of 3 projects') + expect(exitCode).toBe(2) + }) + + it('should report a project killed by a signal rather than as type errors', async () => { + tinyexec.mockReturnValueOnce(killed('SIGKILL')) + + const { output, exitCode } = await runTypecheck(['--per-project']) + + expect(tinyexec).toHaveBeenCalledTimes(3) + expect(output).toContain('was killed by SIGKILL') + expect(output).toContain('out of memory') + expect(output).not.toContain('.nuxt/tsconfig.app.json failed') + expect(exitCode).toBe(1) + }) + + it('should pass each project to golar', async () => { + await mkdir(join(cwd, 'node_modules/golar'), { recursive: true }) + await writeFile(join(cwd, 'node_modules/golar/package.json'), JSON.stringify({ name: 'golar', bin: './bin.js' })) + installed('golar/unstable', '@golar/vue') + + await runTypecheck(['--per-project', '--checker=golar']) + + expect(tinyexec).toHaveBeenCalledWith(expect.stringContaining('golar'), ['tsc', '-p', join(cwd, '.nuxt/tsconfig.app.json'), '--noEmit'], expect.anything()) + }) + + it('should run a single check when there are no project references to split', async () => { + await writeFile(join(cwd, 'tsconfig.json'), JSON.stringify({ include: ['src'] })) + + const { output } = await runTypecheck(['--per-project']) + + expect(output).toContain('Ignoring') + expect(tinyexec).toHaveBeenCalledTimes(1) + expect(tinyexec).toHaveBeenCalledWith(expect.anything(), ['--noEmit'], expect.anything()) + }) + + it('should run a single check with `--no-build`', async () => { + const { output } = await runTypecheck(['--per-project', '--no-build']) + + expect(output).toContain('Ignoring') + expect(tinyexec).toHaveBeenCalledWith(expect.anything(), ['--noEmit'], expect.anything()) + }) +}) diff --git a/packages/nuxt-cli/test/unit/help.spec.ts b/packages/nuxt-cli/test/unit/help.spec.ts index 5180522c8..a42bb98a7 100644 --- a/packages/nuxt-cli/test/unit/help.spec.ts +++ b/packages/nuxt-cli/test/unit/help.spec.ts @@ -452,6 +452,7 @@ describe('help', () => { --checker= Type checker to use -b, --build Type-check in build mode, using TypeScript project references (detected automatically by default) --no-b, --no-build Type-check without TypeScript project references + --per-project Type-check each project reference in its own process, one after another, to lower peak memory usage " `) })