UNPKG

convex

Version:

Client for the Convex Cloud

218 lines (203 loc) 6.72 kB
import chalk from "chalk"; import path from "path"; import { Context, logError, logFailure, showSpinner, } from "../../bundler/context.js"; import * as Sentry from "@sentry/node"; import * as semver from "semver"; import { spawnAsync } from "./utils/utils.js"; export type TypecheckResult = "cantTypeCheck" | "success" | "typecheckFailed"; export type TypeCheckMode = "enable" | "try" | "disable"; type TypecheckResultHandler = ( result: TypecheckResult, logSpecificError?: () => void, // If given, we run it to print out errors. // We expect it to throw or resolve to "success" // if a concurrent change invalidated the error result. runOnError?: () => Promise<"success">, ) => Promise<void>; /** * Conditionally run a typecheck function and interpret the result. * * If typeCheckMode === "disable", never run the typecheck function. * If typeCheckMode === "enable", run the typecheck and crash if typechecking * fails or we can't find tsc. * If typeCheckMode === "try", try and run the typecheck. crash if typechecking * fails but don't worry if tsc is missing and we can't run it. */ export async function typeCheckFunctionsInMode( ctx: Context, typeCheckMode: TypeCheckMode, functionsDir: string, ): Promise<void> { if (typeCheckMode === "disable") { return; } await typeCheckFunctions( ctx, functionsDir, async (result, logSpecificError, runOnError) => { if ( (result === "cantTypeCheck" && typeCheckMode === "enable") || result === "typecheckFailed" ) { logSpecificError?.(); logError( ctx, chalk.gray("To ignore failing typecheck, use `--typecheck=disable`."), ); try { const result = await runOnError?.(); // Concurrent change invalidated the error, don't fail if (result === "success") { return; } } catch (e) { // As expected, `runOnError` threw } await ctx.crash({ exitCode: 1, errorType: "invalid filesystem data", printedMessage: null, }); } }, ); } // Runs TypeScript compiler to typecheck Convex query and mutation functions. export async function typeCheckFunctions( ctx: Context, functionsDir: string, handleResult: TypecheckResultHandler, ): Promise<void> { const tsconfig = path.join(functionsDir, "tsconfig.json"); if (!ctx.fs.exists(tsconfig)) { return handleResult("cantTypeCheck", () => { logError( ctx, "Found no convex/tsconfig.json to use to typecheck Convex functions, so skipping typecheck.", ); logError(ctx, "Run `npx convex codegen --init` to create one."); }); } await runTsc(ctx, ["--project", functionsDir], handleResult); } async function runTsc( ctx: Context, tscArgs: string[], handleResult: TypecheckResultHandler, ): Promise<void> { // Check if tsc is even installed const tscPath = path.join("node_modules", "typescript", "bin", "tsc"); if (!ctx.fs.exists(tscPath)) { return handleResult("cantTypeCheck", () => { logError( ctx, chalk.gray("No TypeScript binary found, so skipping typecheck."), ); }); } // Check the TypeScript version matches the recommendation from Convex const versionResult = await spawnAsync(ctx, process.execPath, [ tscPath, "--version", ]); const version = versionResult.stdout.match(/Version (.*)/)?.[1] ?? null; const hasOlderTypeScriptVersion = version && semver.lt(version, "4.8.4"); await runTscInner(ctx, tscPath, tscArgs, handleResult); // Print this warning after any logs from running `tsc` if (hasOlderTypeScriptVersion) { logError( ctx, chalk.yellow( "Convex works best with TypeScript version 4.8.4 or newer -- npm i --save-dev typescript@latest to update.", ), ); } } async function runTscInner( ctx: Context, tscPath: string, tscArgs: string[], handleResult: TypecheckResultHandler, ) { // Run `tsc` once and have it print out the files it touched. This output won't // be very useful if there's an error, but we'll run it again to get a nice // user-facing error in this exceptional case. // The `--listFiles` command prints out files touched on success or error. const result = await spawnAsync(ctx, process.execPath, [ tscPath, ...tscArgs, "--listFiles", ]); if (result.status === null) { return handleResult("typecheckFailed", () => { logFailure(ctx, `TypeScript typecheck timed out.`); if (result.error) { logError(ctx, chalk.red(`${result.error.toString()}`)); } }); } // Okay, we may have failed `tsc` but at least it returned. Try to parse its // output to discover which files it touched. const filesTouched = result.stdout .split("\n") .map((s) => s.trim()) .filter((s) => s.length > 0); let anyPathsFound = false; for (const fileTouched of filesTouched) { const absPath = path.resolve(fileTouched); let st; try { st = ctx.fs.stat(absPath); anyPathsFound = true; } catch (err: any) { // Just move on if we have a bogus path from `tsc`. We'll log below if // we fail to stat *any* of the paths emitted by `tsc`. // TODO: Switch to using their JS API so we can get machine readable output. continue; } ctx.fs.registerPath(absPath, st); } if (filesTouched.length > 0 && !anyPathsFound) { const err = new Error( `Failed to stat any files emitted by tsc (received ${filesTouched.length})`, ); Sentry.captureException(err); } if (!result.error && result.status === 0) { return handleResult("success"); } // This is the "No inputs were found", which is fine and we shouldn't // report it to the user. if (result.stdout.startsWith("error TS18003")) { return handleResult("success"); } // At this point we know that `tsc` failed. Rerun it without `--listFiles` // and with stderr redirected to have it print out a nice error. return handleResult( "typecheckFailed", () => { logFailure(ctx, "TypeScript typecheck via `tsc` failed."); }, async () => { showSpinner(ctx, "Collecting TypeScript errors"); await spawnAsync( ctx, process.execPath, [tscPath, ...tscArgs, "--pretty", "true"], { stdio: "inherit", }, ); // If this passes, we had a concurrent file change that'll overlap with // our observations in the first run. Invalidate our context's filesystem // but allow the rest of the system to observe the success. ctx.fs.invalidate(); return "success"; }, ); }