v3: Better dev/deploy errors and handle image build errors (#988)

* Better error output when an ESM only package is required

* Add better errors for npm package errors, and bring deploy errors to the dev CLI

* Handle depot build errors and write out a temporary file to view the build logs

* Add changeset
This commit is contained in:
Eric Allam
2024-03-29 12:46:11 +00:00
committed by GitHub
parent 11b997d2bf
commit 8c4df326cc
7 changed files with 321 additions and 53 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"trigger.dev": patch
---
Improve error messages during dev/deploy and handle deploy image build issues
+1 -1
View File
@@ -41,7 +41,7 @@
"type": "node-terminal",
"request": "launch",
"name": "Debug V3 Deploy CLI",
"command": "pnpm exec trigger.dev deploy",
"command": "pnpm exec trigger.dev deploy --skip-deploy",
"cwd": "${workspaceFolder}/references/v3-catalog",
"sourceMaps": true
},
+3 -1
View File
@@ -5,6 +5,7 @@ import { getTracer, provider } from "../telemetry/tracing";
import { fromZodError } from "zod-validation-error";
import { logger } from "../utilities/logger";
import { outro } from "@clack/prompts";
import { chalkError } from "../utilities/cliOutput";
export const CommonCommandOptions = z.object({
apiUrl: z.string().optional(),
@@ -84,7 +85,8 @@ export async function wrapCommandAction<T extends z.AnyZodObject, TResult>(
// do nothing
} else {
recordSpanException(span, e);
logger.error(e instanceof Error ? e.message : String(e));
logger.log(`${chalkError("X Error:")} ${e instanceof Error ? e.message : String(e)}`);
}
span.end();
+118 -34
View File
@@ -43,9 +43,15 @@ import { logger } from "../utilities/logger.js";
import { createTaskFileImports, gatherTaskFiles } from "../utilities/taskFiles";
import { login } from "./login";
import { Glob } from "glob";
import type { SetOptional } from "type-fest";
import { bundleDependenciesPlugin, workerSetupImportConfigPlugin } from "../utilities/build";
import { Glob } from "glob";
import { chalkError, chalkPurple, chalkWarning } from "../utilities/cliOutput";
import {
logESMRequireError,
parseBuildErrorStack,
parseNpmInstallError,
} from "../utilities/deployErrors";
const DeployCommandOptions = CommonCommandOptions.extend({
skipTypecheck: z.boolean().default(false),
@@ -272,28 +278,44 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) {
);
}
return buildAndPushImage({
registryHost,
auth: authorization.auth.accessToken,
imageTag: deploymentResponse.data.imageTag,
buildId: deploymentResponse.data.externalBuildData.buildId,
buildToken: deploymentResponse.data.externalBuildData.buildToken,
buildProjectId: deploymentResponse.data.externalBuildData.projectId,
cwd: compilation.path,
projectId: resolvedConfig.config.project,
deploymentId: deploymentResponse.data.id,
deploymentVersion: deploymentResponse.data.version,
contentHash: deploymentResponse.data.contentHash,
projectRef: resolvedConfig.config.project,
loadImage: options.loadImage,
buildPlatform: options.buildPlatform,
});
return buildAndPushImage(
{
registryHost,
auth: authorization.auth.accessToken,
imageTag: deploymentResponse.data.imageTag,
buildId: deploymentResponse.data.externalBuildData.buildId,
buildToken: deploymentResponse.data.externalBuildData.buildToken,
buildProjectId: deploymentResponse.data.externalBuildData.projectId,
cwd: compilation.path,
projectId: resolvedConfig.config.project,
deploymentId: deploymentResponse.data.id,
deploymentVersion: deploymentResponse.data.version,
contentHash: deploymentResponse.data.contentHash,
projectRef: resolvedConfig.config.project,
loadImage: options.loadImage,
buildPlatform: options.buildPlatform,
},
deploymentSpinner
);
};
const image = await buildImage();
if (!image.ok) {
deploymentSpinner.stop(`Failed to build project image: ${image.error}`);
deploymentSpinner.stop(`Failed to build project.`);
// If there are logs, let's write it out to a temporary file and include the path in the error message
if (image.logs.trim() !== "") {
const logPath = join(await createTempDir(), `build-${deploymentResponse.data.shortCode}.log`);
await writeFile(logPath, image.logs);
logger.log(
`${chalkError("X Error:")} ${image.error}. Full build logs have been saved to ${logPath})`
);
} else {
logger.log(`${chalkError("X Error:")} ${image.error}.`);
}
throw new SkipLoggingError(`Failed to build project image: ${image.error}`);
}
@@ -379,10 +401,19 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) {
}
case "FAILED": {
if (finishedDeployment.errorData) {
deploymentSpinner.stop(
`Deployment encountered an error: ${finishedDeployment.errorData.name}. ${deploymentLink}`
);
logger.error(finishedDeployment.errorData.stack);
const parsedError = finishedDeployment.errorData.stack
? parseBuildErrorStack(finishedDeployment.errorData)
: finishedDeployment.errorData.message;
if (typeof parsedError === "string") {
deploymentSpinner.stop(`Deployment encountered an error. ${deploymentLink}`);
logger.log(`${chalkError("X Error:")} ${parsedError}`);
} else {
deploymentSpinner.stop(`Deployment encountered an error. ${deploymentLink}`);
logESMRequireError(parsedError, resolvedConfig);
}
throw new SkipLoggingError(
`Deployment encountered an error: ${finishedDeployment.errorData.name}`
@@ -551,15 +582,18 @@ type BuildAndPushImageResults =
| {
ok: true;
image: string;
logs: string;
digest?: string;
}
| {
ok: false;
error: string;
logs: string;
};
async function buildAndPushImage(
options: BuildAndPushImageOptions
options: BuildAndPushImageOptions,
updater: ReturnType<typeof spinner>
): Promise<BuildAndPushImageResults> {
return tracer.startActiveSpan("buildAndPushImage", async (span) => {
span.setAttributes({
@@ -626,7 +660,7 @@ async function buildAndPushImage(
const errors: string[] = [];
try {
await new Promise<void>((res, rej) => {
const processCode = await new Promise<number | null>((res, rej) => {
// For some reason everything is output on stderr, not stdout
childProcess.stderr?.on("data", (data: Buffer) => {
const text = data.toString();
@@ -636,9 +670,19 @@ async function buildAndPushImage(
});
childProcess.on("error", (e) => rej(e));
childProcess.on("close", () => res());
childProcess.on("close", (code) => res(code));
});
const logs = extractLogs(errors);
if (processCode !== 0) {
return {
ok: false as const,
error: `Error building image`,
logs,
};
}
const digest = extractImageDigest(errors);
span.setAttributes({
@@ -650,6 +694,7 @@ async function buildAndPushImage(
return {
ok: true as const,
image: options.imageTag,
logs,
digest,
};
} catch (e) {
@@ -659,6 +704,7 @@ async function buildAndPushImage(
return {
ok: false as const,
error: e instanceof Error ? e.message : JSON.stringify(e),
logs: extractLogs(errors),
};
}
});
@@ -751,6 +797,7 @@ async function buildAndPushSelfHostedImage(
return {
ok: false as const,
error: e instanceof Error ? e.message : JSON.stringify(e),
logs: extractLogs(errors),
};
}
@@ -793,6 +840,7 @@ async function buildAndPushSelfHostedImage(
return {
ok: false as const,
error: e instanceof Error ? e.message : JSON.stringify(e),
logs: extractLogs(errors),
};
}
}
@@ -803,6 +851,7 @@ async function buildAndPushSelfHostedImage(
ok: true as const,
image: options.imageTag,
digest,
logs: extractLogs(errors),
};
});
}
@@ -820,6 +869,13 @@ function extractImageDigest(outputs: string[]) {
}
}
function extractLogs(outputs: string[]) {
// Remove empty lines
const cleanedOutputs = outputs.map((line) => line.trim()).filter((line) => line !== "");
return cleanedOutputs.map((line) => line.trim()).join("\n");
}
async function compileProject(
config: ResolvedConfig,
options: DeployCommandOptions,
@@ -1057,7 +1113,7 @@ async function compileProject(
);
if (!resolvingDependenciesResult) {
throw new Error("Failed to resolve dependencies");
throw new SkipLoggingError("Failed to resolve dependencies");
}
// Write the Containerfile to /tmp/dir/Containerfile
@@ -1188,15 +1244,39 @@ async function resolveDependencies(
return true;
} catch (installError) {
logger.debug(`Failed to resolve dependencies: ${JSON.stringify(installError)}`);
recordSpanException(span, installError);
span.end();
resolvingDepsSpinner.stop(
"Failed to resolve dependencies. Rerun with --log-level=debug for more information"
);
const parsedError = parseNpmInstallError(installError);
if (typeof parsedError === "string") {
resolvingDepsSpinner.stop(`Failed to resolve dependencies: ${parsedError}`);
} else {
switch (parsedError.type) {
case "package-not-found-error": {
resolvingDepsSpinner.stop(`Failed to resolve dependencies`);
logger.log(
`\n${chalkError("X Error:")} The package ${chalkPurple(
parsedError.packageName
)} could not be found in the npm registry.`
);
break;
}
case "no-matching-version-error": {
resolvingDepsSpinner.stop(`Failed to resolve dependencies`);
logger.log(
`\n${chalkError("X Error:")} The package ${chalkPurple(
parsedError.packageName
)} could not resolve because the version doesn't exist`
);
break;
}
}
}
return false;
}
@@ -1312,8 +1392,12 @@ async function gatherRequiredDependencies(
dependencies[packageParts.name] = externalDependencyVersion;
continue;
} else {
logger.warn(
`Could not find version for package ${packageName}, add a version specifier to the package name (e.g. ${packageParts.name}@latest) or add it to your project's package.json`
logger.log(
`${chalkWarning("X Warning:")} Could not find version for package ${chalkPurple(
packageName
)}, add a version specifier to the package name (e.g. ${
packageParts.name
}@latest) or add it to your project's package.json`
);
}
}
+46 -6
View File
@@ -41,6 +41,11 @@ import { createTaskFileImports, gatherTaskFiles } from "../utilities/taskFiles";
import { UncaughtExceptionError } from "../workers/common/errors";
import { BackgroundWorker, BackgroundWorkerCoordinator } from "../workers/dev/backgroundWorker.js";
import { runtimeCheck } from "../utilities/runtimeCheck";
import {
logESMRequireError,
parseBuildErrorStack,
parseNpmInstallError,
} from "../utilities/deployErrors";
let apiClient: CliApiClient | undefined;
@@ -544,20 +549,55 @@ function useDev({
);
} catch (e) {
if (e instanceof UncaughtExceptionError) {
const parsedBuildError = parseBuildErrorStack(e.originalError);
if (typeof parsedBuildError !== "string") {
logESMRequireError(
parsedBuildError,
configPath
? { status: "file", path: configPath, config }
: { status: "in-memory", config }
);
return;
} else {
}
if (e.originalError.stack) {
logger.error("Background worker failed to start", e.originalError.stack);
logger.log(
`${chalkError("X Error:")} Worker failed to start`,
e.originalError.stack
);
}
return;
}
if (e instanceof Error) {
logger.error(`Background worker failed to start`, e.stack);
const parsedError = parseNpmInstallError(e);
return;
if (typeof parsedError === "string") {
logger.log(`${chalkError("X Error:")} ${parsedError}`);
} else {
switch (parsedError.type) {
case "package-not-found-error": {
logger.log(
`\n${chalkError("X Error:")} The package ${chalkPurple(
parsedError.packageName
)} could not be found in the npm registry.`
);
break;
}
case "no-matching-version-error": {
logger.log(
`\n${chalkError("X Error:")} The package ${chalkPurple(
parsedError.packageName
)} could not resolve because the version doesn't exist`
);
break;
}
}
}
logger.error(`Background worker failed to start: ${e}`);
}
});
},
@@ -0,0 +1,146 @@
import chalk from "chalk";
import { relative } from "node:path";
import { chalkError, chalkPurple, chalkGrey, chalkGreen } from "./cliOutput";
import { logger } from "./logger";
import { ReadConfigResult } from "./configFiles";
export type ESMRequireError = {
type: "esm-require-error";
moduleName: string;
};
export type BuildError = ESMRequireError | string;
function errorIsErrorLike(error: unknown): error is Error {
return (
error instanceof Error || (typeof error === "object" && error !== null && "message" in error)
);
}
export function parseBuildErrorStack(error: unknown): BuildError {
if (typeof error === "string") {
return error;
}
if (errorIsErrorLike(error)) {
if (typeof error.stack === "string") {
const isErrRequireEsm = error.stack.includes("ERR_REQUIRE_ESM");
let moduleName = null;
if (isErrRequireEsm) {
// Regular expression to match the module path
const moduleRegex = /node_modules\/(@[^\/]+\/[^\/]+|[^\/]+)\/[^\/]+\s/;
const match = moduleRegex.exec(error.stack);
if (match) {
moduleName = match[1] as string; // Capture the module name
return {
type: "esm-require-error",
moduleName,
};
}
}
} else {
return error.message;
}
}
return "Unknown error";
}
export function logESMRequireError(parsedError: ESMRequireError, resolvedConfig: ReadConfigResult) {
logger.log(
`\n${chalkError("X Error:")} The ${chalkPurple(
parsedError.moduleName
)} module is being required even though it's ESM only, and builds only support CommonJS. There are two ${chalk.underline(
"possible"
)} ways to fix this:`
);
logger.log(
`\n${chalkGrey("○")} Dynamically import the module in your code: ${chalkGrey(
`const myModule = await import("${parsedError.moduleName}");`
)}`
);
if (resolvedConfig.status === "file") {
const relativePath = relative(resolvedConfig.config.projectDir, resolvedConfig.path).replace(
/\\/g,
"/"
);
logger.log(
`${chalkGrey("○")} ${chalk.underline("Or")} add ${chalkPurple(
parsedError.moduleName
)} to the ${chalkGreen("dependenciesToBundle")} array in your config file ${chalkGrey(
`(${relativePath})`
)}. This will bundle the module with your code.\n`
);
} else {
logger.log(
`${chalkGrey("○")} ${chalk.underline("Or")} add ${chalkPurple(
parsedError.moduleName
)} to the ${chalkGreen("dependenciesToBundle")} array in your config file ${chalkGrey(
"(you'll need to create one)"
)}. This will bundle the module with your code.\n`
);
}
}
export type PackageNotFoundError = {
type: "package-not-found-error";
packageName: string;
};
export type NoMatchingVersionError = {
type: "no-matching-version-error";
packageName: string;
};
export type NpmInstallError = PackageNotFoundError | NoMatchingVersionError | string;
export function parseNpmInstallError(error: unknown): NpmInstallError {
if (typeof error === "string") {
return error;
}
if (error instanceof Error) {
if (typeof error.stack === "string") {
const isPackageNotFoundError =
error.stack.includes("ERR! 404 Not Found") &&
error.stack.includes("is not in this registry");
let packageName = null;
if (isPackageNotFoundError) {
// Regular expression to match the package name
const packageNameRegex = /'([^']+)' is not in this registry/;
const match = packageNameRegex.exec(error.stack);
if (match) {
packageName = match[1] as string; // Capture the package name
}
}
if (packageName) {
return {
type: "package-not-found-error",
packageName,
};
}
const noMatchingVersionRegex = /No matching version found for ([^\s]+)\s/;
const noMatchingVersionMatch = noMatchingVersionRegex.exec(error.stack);
if (noMatchingVersionMatch) {
return {
type: "no-matching-version-error",
packageName: (noMatchingVersionMatch[1] as string).replace(/.$/, ""),
};
}
return error.message;
} else {
return error.message;
}
}
return "Unknown error";
}
@@ -15,23 +15,14 @@ export async function installPackages(
await setPackageJsonDeps(join(cwd, "package.json"), packages);
const childProcess = execa(
await execa(
"npm",
["install", "--install-strategy", "nested", "--ignore-scripts", "--no-audit", "--no-fund"],
{
cwd,
stderr: "inherit",
stderr: "pipe",
}
);
await new Promise<void>((res, rej) => {
childProcess.on("error", (e) => rej(e));
childProcess.on("close", () => res());
});
await childProcess;
return;
}
async function getPackageVersion(path: string) {