convex
Version:
Client for the Convex Cloud
218 lines (203 loc) • 6.72 kB
text/typescript
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";
},
);
}