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:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"trigger.dev": patch
|
||||
---
|
||||
|
||||
Improve error messages during dev/deploy and handle deploy image build issues
|
||||
Vendored
+1
-1
@@ -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
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user