diff --git a/.changeset/ten-cheetahs-perform.md b/.changeset/ten-cheetahs-perform.md new file mode 100644 index 000000000..3c7fac6e7 --- /dev/null +++ b/.changeset/ten-cheetahs-perform.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Improve error messages related to CSS and compiler errors diff --git a/packages/astro/e2e/errors.test.js b/packages/astro/e2e/errors.test.js index c29ec0d4f..9742df49c 100644 --- a/packages/astro/e2e/errors.test.js +++ b/packages/astro/e2e/errors.test.js @@ -38,7 +38,7 @@ test.describe('Error display', () => { await page.goto(astro.resolveUrl('/import-not-found')); const message = await getErrorOverlayMessage(page); - expect(message).toMatch('failed to load module for ssr: ../abc.astro'); + expect(message).toMatch('Could not import "../abc.astro"'); await Promise.all([ // Wait for page reload diff --git a/packages/astro/src/cli/index.ts b/packages/astro/src/cli/index.ts index da05d989e..a8ead6707 100644 --- a/packages/astro/src/cli/index.ts +++ b/packages/astro/src/cli/index.ts @@ -16,7 +16,7 @@ import { } from '../core/config/index.js'; import { ASTRO_VERSION } from '../core/constants.js'; import devServer from '../core/dev/index.js'; -import { collectErrorMetadata } from '../core/errors.js'; +import { collectErrorMetadata } from '../core/errors/dev/index.js'; import { debug, error, info, LogOptions } from '../core/logger/core.js'; import { enableVerboseLogging, nodeLogDestination } from '../core/logger/node.js'; import { formatConfigErrorMessage, formatErrorMessage, printHelp } from '../core/messages.js'; diff --git a/packages/astro/src/core/build/index.ts b/packages/astro/src/core/build/index.ts index 25e6717d7..e4b93d9c1 100644 --- a/packages/astro/src/core/build/index.ts +++ b/packages/astro/src/core/build/index.ts @@ -13,7 +13,7 @@ import { runHookConfigSetup, } from '../../integrations/index.js'; import { createVite } from '../create-vite.js'; -import { fixViteErrorMessage } from '../errors.js'; +import { enhanceViteSSRError } from '../errors/dev/index.js'; import { debug, info, levels, timerMessage } from '../logger/core.js'; import { apply as applyPolyfill } from '../polyfill.js'; import { RouteCache } from '../render/route-cache.js'; @@ -169,7 +169,7 @@ class AstroBuilder { try { await this.build(setupData); } catch (_err) { - throw fixViteErrorMessage(_err); + throw enhanceViteSSRError(_err as Error); } } diff --git a/packages/astro/src/core/compile/compile.ts b/packages/astro/src/core/compile/compile.ts index 776e7088f..f3fe01f0c 100644 --- a/packages/astro/src/core/compile/compile.ts +++ b/packages/astro/src/core/compile/compile.ts @@ -3,9 +3,10 @@ import type { AstroConfig } from '../../@types/astro'; import type { TransformStyle } from './types'; import { transform } from '@astrojs/compiler'; -import { AstroErrorCodes } from '../errors.js'; +import { AstroErrorCodes } from '../errors/codes.js'; +import { AggregateError, AstroError, CompilerError } from '../errors/errors.js'; import { prependForwardSlash } from '../path.js'; -import { AggregateError, resolvePath, viteID } from '../util.js'; +import { resolvePath, viteID } from '../util.js'; import { createStylePreprocessor } from './style.js'; type CompilationCache = Map; @@ -30,7 +31,7 @@ async function compile({ transformStyle, }: CompileProps): Promise { let cssDeps = new Set(); - let cssTransformErrors: Error[] = []; + let cssTransformErrors: AstroError[] = []; // Transform from `.astro` to valid `.ts` // use `sourcemap: "both"` so that sourcemap is included in the code @@ -51,26 +52,51 @@ async function compile({ return resolvePath(specifier, filename); }, }) - .catch((err) => { - // throw compiler errors here if encountered - err.code = err.code || AstroErrorCodes.UnknownCompilerError; - throw err; + .catch((err: Error) => { + // The compiler should be able to handle errors by itself, however + // for the rare cases where it can't let's directly throw here with as much info as possible + throw new CompilerError({ + errorCode: AstroErrorCodes.UnknownCompilerError, + message: err.message ?? 'Unknown compiler error', + stack: err.stack, + location: { + file: filename, + }, + }); }) .then((result) => { + const compilerError = result.diagnostics.find( + // HACK: The compiler currently mistakenly returns the wrong severity for warnings, so we'll also filter by code + // https://github.com/withastro/compiler/issues/595 + (diag) => diag.severity === 1 && diag.code < 2000 + ); + + if (compilerError) { + throw new CompilerError({ + errorCode: compilerError.code, + message: compilerError.text, + location: { + line: compilerError.location.line, + column: compilerError.location.column, + file: compilerError.location.file, + }, + hint: compilerError.hint ? compilerError.hint : undefined, + }); + } + switch (cssTransformErrors.length) { case 0: return result; case 1: { let error = cssTransformErrors[0]; - if (!(error as any).code) { - (error as any).code = AstroErrorCodes.UnknownCompilerCSSError; + if (!error.errorCode) { + error.errorCode = AstroErrorCodes.UnknownCompilerCSSError; } + throw cssTransformErrors[0]; } default: { - const aggregateError = new AggregateError(cssTransformErrors); - (aggregateError as any).code = AstroErrorCodes.UnknownCompilerCSSError; - throw aggregateError; + throw new AggregateError({ ...cssTransformErrors[0], errors: cssTransformErrors }); } } }); diff --git a/packages/astro/src/core/create-vite.ts b/packages/astro/src/core/create-vite.ts index 63e780961..62dc46eb3 100644 --- a/packages/astro/src/core/create-vite.ts +++ b/packages/astro/src/core/create-vite.ts @@ -16,7 +16,7 @@ import legacyMarkdownVitePlugin from '../vite-plugin-markdown-legacy/index.js'; import markdownVitePlugin from '../vite-plugin-markdown/index.js'; import astroScriptsPlugin from '../vite-plugin-scripts/index.js'; import astroScriptsPageSSRPlugin from '../vite-plugin-scripts/page-ssr.js'; -import { createCustomViteLogger } from './errors.js'; +import { createCustomViteLogger } from './errors/dev/index.js'; import { resolveDependency } from './util.js'; interface CreateViteOptions { diff --git a/packages/astro/src/core/errors.ts b/packages/astro/src/core/errors.ts deleted file mode 100644 index 06940276b..000000000 --- a/packages/astro/src/core/errors.ts +++ /dev/null @@ -1,209 +0,0 @@ -import type { BuildResult } from 'esbuild'; -import type { ErrorPayload, Logger, LogLevel, ViteDevServer } from 'vite'; -import type { SSRError } from '../@types/astro'; - -import eol from 'eol'; -import fs from 'fs'; -import { fileURLToPath } from 'node:url'; -import stripAnsi from 'strip-ansi'; -import { createLogger } from 'vite'; -import { codeFrame, createSafeError } from './util.js'; - -export enum AstroErrorCodes { - // 1xxx: Astro Runtime Errors - UnknownError = 1000, - ConfigError = 1001, - // 2xxx: Astro Compiler Errors - UnknownCompilerError = 2000, - UnknownCompilerCSSError = 2001, -} -export interface ErrorWithMetadata { - [name: string]: any; - message: string; - stack: string; - code?: number; - hint?: string; - id?: string; - frame?: string; - plugin?: string; - pluginCode?: string; - loc?: { - file?: string; - line: number; - column: number; - }; -} - -export function cleanErrorStack(stack: string) { - return stack - .split(/\n/g) - .map((l) => l.replace(/\/@fs\//g, '/')) - .join('\n'); -} - -/** - * Update the error message to correct any vite-isms that we don't want to expose to the user. - * The `server` is required if the error may come from `server.ssrLoadModule()`. - */ -export function fixViteErrorMessage(_err: unknown, server?: ViteDevServer, filePath?: URL) { - const err = createSafeError(_err); - // Vite will give you better stacktraces, using sourcemaps. - try { - server?.ssrFixStacktrace(err); - } catch {} - - // Fix: Astro.glob() compiles to import.meta.glob() by the time Vite sees it, - // so we need to update this error message in case it originally came from Astro.glob(). - if (err.message === 'import.meta.glob() can only accept string literals.') { - err.message = 'Astro.glob() and import.meta.glob() can only accept string literals.'; - } - if (filePath && /failed to load module for ssr:/.test(err.message)) { - const importName = err.message.split('for ssr:').at(1)?.trim(); - if (importName) { - try { - const content = fs.readFileSync(fileURLToPath(filePath)).toString(); - const lns = content.split('\n'); - const line = lns.findIndex((ln) => ln.includes(importName)); - if (line == -1) return err; - const column = lns[line]?.indexOf(importName); - if (!(err as any).id) { - (err as any).id = `${fileURLToPath(filePath)}:${line + 1}:${column + 1}`; - } - } catch {} - } - } - return err; -} - -const incompatiblePackages = { - 'react-spectrum': `@adobe/react-spectrum is not compatible with Vite's server-side rendering mode at the moment. You can still use React Spectrum from the client. Create an island React component and use the client:only directive. From there you can use React Spectrum.`, -}; -const incompatPackageExp = new RegExp(`(${Object.keys(incompatiblePackages).join('|')})`); - -export function createCustomViteLogger(logLevel: LogLevel): Logger { - const viteLogger = createLogger(logLevel); - const logger: Logger = { - ...viteLogger, - error(msg, options?) { - // Silence warnings from incompatible packages (we log better errors for these) - if (incompatPackageExp.test(msg)) return; - return viteLogger.error(msg, options); - }, - }; - return logger; -} - -function generateHint(err: ErrorWithMetadata, filePath?: URL): string | undefined { - if (/Unknown file extension \"\.(jsx|vue|svelte|astro|css)\" for /.test(err.message)) { - return 'You likely need to add this package to `vite.ssr.noExternal` in your astro config file.'; - } else if ( - err.toString().startsWith('ReferenceError') && - (err.loc?.file ?? filePath?.pathname)?.endsWith('.astro') - ) { - return 'export statements in `.astro` files do not have access to local variable declarations, only imported values.'; - } else { - const res = incompatPackageExp.exec(err.stack); - if (res) { - const key = res[0] as keyof typeof incompatiblePackages; - return incompatiblePackages[key]; - } - } - return undefined; -} - -/** - * Takes any error-like object and returns a standardized Error + metadata object. - * Useful for consistent reporting regardless of where the error surfaced from. - */ -export function collectErrorMetadata(e: any, filePath?: URL): ErrorWithMetadata { - const err = e as SSRError; - - if ((e as any).stack) { - // normalize error stack line-endings to \n - (e as any).stack = eol.lf((e as any).stack); - // derive error location from stack (if possible) - const stackText = stripAnsi(e.stack); - // TODO: this could be better, `src` might be something else - const possibleFilePath = - err.pluginCode || - err.id || - stackText.split('\n').find((ln) => ln.includes('src') || ln.includes('node_modules')); - const source = possibleFilePath?.replace(/^[^(]+\(([^)]+).*$/, '$1').replace(/^\s+at\s+/, ''); - const [file, line, column] = source?.split(':') ?? []; - if (!err.loc && line && column) { - err.loc = { - file, - line: Number.parseInt(line), - column: Number.parseInt(column), - }; - } - - // Derive plugin from stack (if possible) - if (!err.plugin) { - err.plugin = - /withastro\/astro\/packages\/integrations\/([\w-]+)/gim.exec(stackText)?.at(1) || - /(@astrojs\/[\w-]+)\/(server|client|index)/gim.exec(stackText)?.at(1) || - undefined; - } - - // Normalize stack (remove `/@fs/` urls, etc) - err.stack = cleanErrorStack(e.stack); - } - - if (e.name === 'YAMLException') { - err.loc = { file: (e as any).id, line: (e as any).mark.line, column: (e as any).mark.column }; - err.message = (e as any).reason; - } - - if (!err.frame && err.loc) { - try { - const fileContents = fs.readFileSync(err.loc.file!, 'utf8'); - const frame = codeFrame(fileContents, err.loc); - err.frame = frame; - } catch {} - } - - // Astro error (thrown by esbuild so it needs to be formatted for Vite) - if (Array.isArray((e as any).errors)) { - const { location, pluginName, text } = (e as BuildResult).errors[0]; - if (location) { - err.loc = { file: location.file, line: location.line, column: location.column }; - err.id = err.id || location?.file; - } - const possibleFilePath = err.pluginCode || err.id || location?.file; - if (possibleFilePath && !err.frame) { - try { - const fileContents = fs.readFileSync(possibleFilePath, 'utf8'); - err.frame = codeFrame(fileContents, err.loc); - } catch { - // do nothing, code frame isn't that big a deal - } - } - if (pluginName) { - err.plugin = pluginName; - } - err.hint = generateHint(err, filePath); - return err; - } - - // Generic error (probably from Vite, and already formatted) - err.hint = generateHint(e, filePath); - return err; -} - -export function getViteErrorPayload(err: ErrorWithMetadata): ErrorPayload { - let plugin = err.plugin; - if (!plugin && err.hint) { - plugin = 'astro'; - } - const message = `${err.message}\n\n${err.hint ?? ''}`; - return { - type: 'error', - err: { - ...err, - plugin, - message: message.trim(), - stack: err.stack, - }, - }; -} diff --git a/packages/astro/src/core/errors/codes.ts b/packages/astro/src/core/errors/codes.ts new file mode 100644 index 000000000..24270bed2 --- /dev/null +++ b/packages/astro/src/core/errors/codes.ts @@ -0,0 +1,24 @@ +export enum AstroErrorCodes { + // 1xxx are reserved for compiler errors + StaticRedirectNotAllowed = 2005, + UnavailableInSSR = 2006, + // Runtime errors + GenericRuntimeError = 3000, + // PostCSS errors + CssSyntaxError = 4000, + CssUnknownError = 4001, + // Vite SSR errors + FailedToLoadModuleSSR = 5000, + // Config Errors + ConfigError = 6000, + + // Markdown Errors + GenericMarkdownError = 7000, + MarkdownFrontmatterParseError = 7001, + + // General catch-alls for cases where we have zero information + UnknownCompilerError = 9000, + UnknownCompilerCSSError = 9001, + UnknownViteSSRError = 9002, + UnknownError = 9999, +} diff --git a/packages/astro/src/core/errors/dev/index.ts b/packages/astro/src/core/errors/dev/index.ts new file mode 100644 index 000000000..93cd41385 --- /dev/null +++ b/packages/astro/src/core/errors/dev/index.ts @@ -0,0 +1,2 @@ +export { collectErrorMetadata } from './utils.js'; +export { createCustomViteLogger, enhanceViteSSRError, getViteErrorPayload } from './vite.js'; diff --git a/packages/astro/src/core/errors/dev/utils.ts b/packages/astro/src/core/errors/dev/utils.ts new file mode 100644 index 000000000..44aed1889 --- /dev/null +++ b/packages/astro/src/core/errors/dev/utils.ts @@ -0,0 +1,88 @@ +import type { BuildResult } from 'esbuild'; +import * as fs from 'node:fs'; +import type { SSRError } from '../../../@types/astro.js'; +import { AggregateError, ErrorWithMetadata } from '../errors.js'; +import { codeFrame } from '../printer.js'; +import { collectInfoFromStacktrace } from '../utils.js'; + +export const incompatiblePackages = { + 'react-spectrum': `@adobe/react-spectrum is not compatible with Vite's server-side rendering mode at the moment. You can still use React Spectrum from the client. Create an island React component and use the client:only directive. From there you can use React Spectrum.`, +}; +export const incompatPackageExp = new RegExp(`(${Object.keys(incompatiblePackages).join('|')})`); + +/** + * Takes any error-like object and returns a standardized Error + metadata object. + * Useful for consistent reporting regardless of where the error surfaced from. + */ +export function collectErrorMetadata(e: any, filePath?: URL): ErrorWithMetadata { + const err = AggregateError.is(e) ? (e.errors as SSRError[]) : [e as SSRError]; + + err.forEach((error) => { + if (error.stack) { + error = collectInfoFromStacktrace(e); + } + + // If we don't have a frame, but we have a location let's try making up a frame for it + if (!error.frame && error.loc) { + try { + const fileContents = fs.readFileSync(error.loc.file!, 'utf8'); + const frame = codeFrame(fileContents, error.loc); + error.frame = frame; + } catch {} + } + + // Generic error (probably from Vite, and already formatted) + if (!error.hint) { + error.hint = generateHint(e, filePath); + } + }); + + // If we received an array of errors and it's not from us, it should be from ESBuild, try to extract info for Vite to display + if (!AggregateError.is(e) && Array.isArray((e as any).errors)) { + (e as BuildResult).errors.forEach((buildError, i) => { + const { location, pluginName } = buildError; + + if (location) { + err[i].loc = { file: location.file, line: location.line, column: location.column }; + err[i].id = err[0].id || location?.file; + } + + const possibleFilePath = err[i].pluginCode || err[i].id || location?.file; + if (possibleFilePath && !err[i].frame) { + try { + const fileContents = fs.readFileSync(possibleFilePath, 'utf8'); + err[i].frame = codeFrame(fileContents, { ...err[i].loc, file: possibleFilePath }); + } catch { + // do nothing, code frame isn't that big a deal + } + } + + if (pluginName) { + err[i].plugin = pluginName; + } + + err[i].hint = generateHint(err[0], filePath); + }); + } + + // TODO: Handle returning multiple errors + return err[0]; +} + +function generateHint(err: ErrorWithMetadata, filePath?: URL): string | undefined { + if (/Unknown file extension \"\.(jsx|vue|svelte|astro|css)\" for /.test(err.message)) { + return 'You likely need to add this package to `vite.ssr.noExternal` in your astro config file.'; + } else if ( + err.toString().startsWith('ReferenceError') && + (err.loc?.file ?? filePath?.pathname)?.endsWith('.astro') + ) { + return 'export statements in `.astro` files do not have access to local variable declarations, only imported values.'; + } else { + const res = incompatPackageExp.exec(err.stack); + if (res) { + const key = res[0] as keyof typeof incompatiblePackages; + return incompatiblePackages[key]; + } + } + return undefined; +} diff --git a/packages/astro/src/core/errors/dev/vite.ts b/packages/astro/src/core/errors/dev/vite.ts new file mode 100644 index 000000000..4a187c4f8 --- /dev/null +++ b/packages/astro/src/core/errors/dev/vite.ts @@ -0,0 +1,108 @@ +import * as fs from 'fs'; +import { fileURLToPath } from 'url'; +import { + createLogger, + type ErrorPayload, + type Logger, + type LogLevel, + type ViteDevServer, +} from 'vite'; +import { AstroErrorCodes } from '../codes.js'; +import { AstroError, type ErrorWithMetadata } from '../errors.js'; +import { incompatPackageExp } from './utils.js'; + +/** + * Custom logger with better error reporting for incompatible packages + */ +export function createCustomViteLogger(logLevel: LogLevel): Logger { + const viteLogger = createLogger(logLevel); + const logger: Logger = { + ...viteLogger, + error(msg, options?) { + // Silence warnings from incompatible packages (we log better errors for these) + if (incompatPackageExp.test(msg)) return; + return viteLogger.error(msg, options); + }, + }; + return logger; +} + +export function enhanceViteSSRError( + error: Error, + filePath?: URL, + viteServer?: ViteDevServer +): AstroError { + // Vite will give you better stacktraces, using sourcemaps. + if (viteServer) { + try { + viteServer.ssrFixStacktrace(error); + } catch {} + } + + const newError = new AstroError({ + name: error.name, + message: error.message, + location: (error as any).loc, + stack: error.stack, + errorCode: (error as AstroError).errorCode + ? (error as AstroError).errorCode + : AstroErrorCodes.UnknownViteSSRError, + }); + + // Vite has a fairly generic error message when it fails to load a module, let's try to enhance it a bit + // https://github.com/vitejs/vite/blob/ee7c28a46a6563d54b828af42570c55f16b15d2c/packages/vite/src/node/ssr/ssrModuleLoader.ts#L91 + if (filePath && /failed to load module for ssr:/.test(error.message)) { + const importName = error.message.split('for ssr:').at(1)?.trim(); + if (importName) { + newError.setMessage(`Could not import "${importName}"`); + newError.setHint('Make sure the file exists'); + newError.setErrorCode(AstroErrorCodes.FailedToLoadModuleSSR); + + const path = fileURLToPath(filePath); + const content = fs.readFileSync(path).toString(); + const lns = content.split('\n'); + const line = lns.findIndex((ln) => ln.includes(importName)); + + if (line !== -1) { + const column = lns[line]?.indexOf(importName); + + newError.setLocation({ + file: path, + line: line + 1, + column, + }); + } + } + } + + return newError; +} + +/** + * Generate a payload for Vite's error overlay + */ +export function getViteErrorPayload(err: ErrorWithMetadata): ErrorPayload { + let plugin = err.plugin; + if (!plugin && err.hint) { + plugin = 'astro'; + } + const message = `${err.message}\n\n${err.hint ?? ''}`; + // Vite doesn't handle tabs correctly in its frames, so let's replace them with spaces + const frame = err.frame?.replace(/\t/g, ' '); + return { + type: 'error', + err: { + ...err, + frame: frame, + loc: { + file: err.loc?.file, + // If we don't have a line and column, Vite won't make a clickable link, so let's fake 0:0 if we don't have a location + line: err.loc?.line ?? 0, + column: err.loc?.column ?? 0, + }, + plugin, + message: message.trim(), + stack: err.stack, + }, + }; +} diff --git a/packages/astro/src/core/errors/errors.ts b/packages/astro/src/core/errors/errors.ts new file mode 100644 index 000000000..ff4552ffd --- /dev/null +++ b/packages/astro/src/core/errors/errors.ts @@ -0,0 +1,160 @@ +import type { DiagnosticCode } from '@astrojs/compiler/shared/diagnostics.js'; +import { AstroErrorCodes } from './codes.js'; +import { codeFrame } from './printer.js'; + +interface ErrorProperties { + errorCode: AstroErrorCodes | DiagnosticCode; + name?: string; + message?: string; + location?: ErrorLocation; + hint?: string; + stack?: string; + frame?: string; +} + +export interface ErrorLocation { + file?: string; + line?: number; + column?: number; +} + +type ErrorTypes = + | 'CSSError' + | 'CompilerError' + | 'RuntimeError' + | 'MarkdownError' + | 'AstroAggregateError'; + +export class AstroError extends Error { + public errorCode: AstroErrorCodes | DiagnosticCode; + public loc: ErrorLocation | undefined; + public hint: string | undefined; + public frame: string | undefined; + + type: ErrorTypes | undefined; + + constructor(props: ErrorProperties, ...params: any) { + super(...params); + + const { errorCode, name, message, stack, location, hint, frame } = props; + + this.errorCode = errorCode; + if (name) { + this.name = name; + } else { + // If we don't have a name, let's generate one from the code + this.name = AstroErrorCodes[errorCode]; + } + if (message) this.message = message; + // Only set this if we actually have a stack passed, otherwise uses Error's + this.stack = stack ? stack : this.stack; + this.loc = location; + this.hint = hint; + this.frame = frame; + } + + public setErrorCode(errorCode: AstroErrorCodes | DiagnosticCode) { + this.errorCode = errorCode; + this.name = AstroErrorCodes[errorCode]; + } + + public setLocation(location: ErrorLocation): void { + this.loc = location; + } + + public setName(name: string): void { + this.name = name; + } + + public setMessage(message: string): void { + this.message = message; + } + + public setHint(hint: string): void { + this.hint = hint; + } + + public setFrame(source: string, location: ErrorLocation): void { + this.frame = codeFrame(source, location); + } +} + +export class CSSError extends AstroError { + type: ErrorTypes = 'CSSError'; + + static is(err: Error | unknown): boolean { + return (err as CSSError).type === 'CSSError'; + } +} + +export class CompilerError extends AstroError { + type: ErrorTypes = 'CompilerError'; + + constructor( + props: ErrorProperties & { errorCode: DiagnosticCode | AstroErrorCodes.UnknownCompilerError }, + ...params: any + ) { + super(props, ...params); + + this.name = 'CompilerError'; + } + + static is(err: Error | unknown): boolean { + return (err as CompilerError).type === 'CompilerError'; + } +} + +export class RuntimeError extends AstroError { + type: ErrorTypes = 'RuntimeError'; + + static is(err: Error | unknown): boolean { + return (err as RuntimeError).type === 'RuntimeError'; + } +} + +export class MarkdownError extends AstroError { + type: ErrorTypes = 'MarkdownError'; + + static is(err: Error | unknown): boolean { + return (err as MarkdownError).type === 'MarkdownError'; + } +} + +export class AggregateError extends AstroError { + type: ErrorTypes = 'AstroAggregateError'; + errors: AstroError[]; + + // Despite being a collection of errors, AggregateError still needs to have a main error attached to it + // This is because Vite expects every thrown errors handled during HMR to be, well, Error and have a message + constructor(props: ErrorProperties & { errors: AstroError[] }, ...params: any) { + super(props, ...params); + + this.errors = props.errors; + } + + static is(err: Error | unknown): boolean { + return (err as AggregateError).type === 'AstroAggregateError'; + } +} + +/** + * Generic object representing an error with all possible data + * Compatible with both Astro's and Vite's errors + */ +export interface ErrorWithMetadata { + [name: string]: any; + type?: ErrorTypes; + message: string; + stack: string; + code?: number; + hint?: string; + id?: string; + frame?: string; + plugin?: string; + pluginCode?: string; + loc?: { + file?: string; + line?: number; + column?: number; + }; +} diff --git a/packages/astro/src/core/errors/index.ts b/packages/astro/src/core/errors/index.ts new file mode 100644 index 000000000..c41c43933 --- /dev/null +++ b/packages/astro/src/core/errors/index.ts @@ -0,0 +1,12 @@ +export { AstroErrorCodes } from './codes.js'; +export { + AstroError, + CSSError, + CompilerError, + RuntimeError, + MarkdownError, + AggregateError, +} from './errors.js'; +export type { ErrorLocation, ErrorWithMetadata } from './errors'; +export { codeFrame } from './printer.js'; +export { positionAt, collectInfoFromStacktrace } from './utils.js'; diff --git a/packages/astro/src/core/errors/printer.ts b/packages/astro/src/core/errors/printer.ts new file mode 100644 index 000000000..65db14940 --- /dev/null +++ b/packages/astro/src/core/errors/printer.ts @@ -0,0 +1,36 @@ +import eol from 'eol'; +import type { ErrorLocation } from './errors.js'; + +/** Generate a code frame from string and an error location */ +export function codeFrame(src: string, loc: ErrorLocation): string { + if (!loc || loc.line === undefined || loc.column === undefined) { + return ''; + } + const lines = eol + .lf(src) + .split('\n') + .map((ln) => ln.replace(/\t/g, ' ')); + // grab 2 lines before, and 3 lines after focused line + const visibleLines = []; + for (let n = -2; n <= 2; n++) { + if (lines[loc.line + n]) visibleLines.push(loc.line + n); + } + // figure out gutter width + let gutterWidth = 0; + for (const lineNo of visibleLines) { + let w = `> ${lineNo}`; + if (w.length > gutterWidth) gutterWidth = w.length; + } + // print lines + let output = ''; + for (const lineNo of visibleLines) { + const isFocusedLine = lineNo === loc.line - 1; + output += isFocusedLine ? '> ' : ' '; + output += `${lineNo + 1} | ${lines[lineNo]}\n`; + if (isFocusedLine) + output += `${Array.from({ length: gutterWidth }).join(' ')} | ${Array.from({ + length: loc.column, + }).join(' ')}^\n`; + } + return output; +} diff --git a/packages/astro/src/core/errors/utils.ts b/packages/astro/src/core/errors/utils.ts new file mode 100644 index 000000000..471283f11 --- /dev/null +++ b/packages/astro/src/core/errors/utils.ts @@ -0,0 +1,120 @@ +import stripAnsi from 'strip-ansi'; +import type { SSRError } from '../../@types/astro.js'; +import eol from 'eol'; + +export function collectInfoFromStacktrace(error: SSRError): SSRError { + if (!error.stack) return error; + + // normalize error stack line-endings to \n + error.stack = eol.lf(error.stack); + const stackText = stripAnsi(error.stack); + + // Try to find possible location from stack if we don't have one + if (!error.loc || (!error.loc.column && !error.loc.line)) { + const possibleFilePath = + error.loc?.file || + error.pluginCode || + error.id || + // TODO: this could be better, `src` might be something else + stackText.split('\n').find((ln) => ln.includes('src') || ln.includes('node_modules')); + const source = possibleFilePath?.replace(/^[^(]+\(([^)]+).*$/, '$1').replace(/^\s+at\s+/, ''); + + const [file, line, column] = source?.split(':') ?? []; + if (line && column) { + error.loc = { + file, + line: Number.parseInt(line), + column: Number.parseInt(column), + }; + } + } + + // Derive plugin from stack (if possible) + if (!error.plugin) { + error.plugin = + /withastro\/astro\/packages\/integrations\/([\w-]+)/gim.exec(stackText)?.at(1) || + /(@astrojs\/[\w-]+)\/(server|client|index)/gim.exec(stackText)?.at(1) || + undefined; + } + + // Normalize stack (remove `/@fs/` urls, etc) + error.stack = cleanErrorStack(error.stack); + + return error; +} + +function cleanErrorStack(stack: string) { + return stack + .split(/\n/g) + .map((l) => l.replace(/\/@fs\//g, '/')) + .join('\n'); +} + +/** + * Get the line and character based on the offset + * @param offset The index of the position + * @param text The text for which the position should be retrived + */ +export function positionAt( + offset: number, + text: string +): { + line: number; + column: number; +} { + const lineOffsets = getLineOffsets(text); + offset = Math.max(0, Math.min(text.length, offset)); + + let low = 0; + let high = lineOffsets.length; + if (high === 0) { + return { + line: 0, + column: offset, + }; + } + + while (low <= high) { + const mid = Math.floor((low + high) / 2); + const lineOffset = lineOffsets[mid]; + + if (lineOffset === offset) { + return { + line: mid, + column: 0, + }; + } else if (offset > lineOffset) { + low = mid + 1; + } else { + high = mid - 1; + } + } + + // low is the least x for which the line offset is larger than the current offset + // or array.length if no line offset is larger than the current offset + const line = low - 1; + return { line, column: offset - lineOffsets[line] }; +} + +function getLineOffsets(text: string) { + const lineOffsets = []; + let isLineStart = true; + + for (let i = 0; i < text.length; i++) { + if (isLineStart) { + lineOffsets.push(i); + isLineStart = false; + } + const ch = text.charAt(i); + isLineStart = ch === '\r' || ch === '\n'; + if (ch === '\r' && i + 1 < text.length && text.charAt(i + 1) === '\n') { + i++; + } + } + + if (isLineStart && text.length > 0) { + lineOffsets.push(text.length); + } + + return lineOffsets; +} diff --git a/packages/astro/src/core/messages.ts b/packages/astro/src/core/messages.ts index 278f8ac9f..adb62fc39 100644 --- a/packages/astro/src/core/messages.ts +++ b/packages/astro/src/core/messages.ts @@ -18,7 +18,7 @@ import type { AddressInfo } from 'net'; import os from 'os'; import { ResolvedServerUrls } from 'vite'; import { ZodError } from 'zod'; -import { ErrorWithMetadata } from './errors.js'; +import { ErrorWithMetadata } from './errors/index.js'; import { removeTrailingForwardSlash } from './path.js'; import { emoji, getLocalAddress, padMultilineString } from './util.js'; @@ -257,9 +257,15 @@ export function formatErrorMessage(err: ErrorWithMetadata, args: string[] = []): args.push(` ${bold('Hint:')}`); args.push(yellow(padMultilineString(err.hint, 4))); } - if (err.id) { + if (err.id || err.loc?.file) { args.push(` ${bold('File:')}`); - args.push(red(` ${err.id}`)); + args.push( + red( + ` ${err.id ?? err.loc?.file}${ + err.loc?.line && err.loc.column ? `:${err.loc.line}:${err.loc.column}` : '' + }` + ) + ); } if (err.frame) { args.push(` ${bold('Code:')}`); diff --git a/packages/astro/src/core/render/dev/index.ts b/packages/astro/src/core/render/dev/index.ts index 72c55766d..727007c35 100644 --- a/packages/astro/src/core/render/dev/index.ts +++ b/packages/astro/src/core/render/dev/index.ts @@ -9,6 +9,8 @@ import type { SSRLoadedRenderer, } from '../../../@types/astro'; import { PAGE_SCRIPT_ID } from '../../../vite-plugin-scripts/index.js'; +import { enhanceViteSSRError } from '../../errors/dev/index.js'; +import { MarkdownError, CSSError, AggregateError } from '../../errors/index.js'; import { LogOptions } from '../../logger/core.js'; import { isPage, resolveIdToUrl } from '../../util.js'; import { createRenderContext, renderPage as coreRenderPage } from '../index.js'; @@ -91,9 +93,19 @@ export async function preload({ }: Pick): Promise { // Important: This needs to happen first, in case a renderer provides polyfills. const renderers = await loadRenderers(env.viteServer, env.settings); - // Load the module from the Vite SSR Runtime. - const mod = (await env.viteServer.ssrLoadModule(fileURLToPath(filePath))) as ComponentInstance; - return [renderers, mod]; + + try { + // Load the module from the Vite SSR Runtime. + const mod = (await env.viteServer.ssrLoadModule(fileURLToPath(filePath))) as ComponentInstance; + return [renderers, mod]; + } catch (err) { + // If the error came from Markdown or CSS, we already handled it and there's no need to enhance it + if (MarkdownError.is(err) || CSSError.is(err) || AggregateError.is(err)) { + throw err; + } + + throw enhanceViteSSRError(err as Error, filePath, env.viteServer); + } } interface GetScriptsAndStylesParams { diff --git a/packages/astro/src/core/util.ts b/packages/astro/src/core/util.ts index 17adce83f..482b7735f 100644 --- a/packages/astro/src/core/util.ts +++ b/packages/astro/src/core/util.ts @@ -1,4 +1,3 @@ -import eol from 'eol'; import fs from 'fs'; import path from 'path'; import resolve from 'resolve'; @@ -99,38 +98,6 @@ export function createSafeError(err: any): Error { : new Error(JSON.stringify(err)); } -/** generate code frame from esbuild error */ -export function codeFrame(src: string, loc: ErrorPayload['err']['loc']): string { - if (!loc) return ''; - const lines = eol - .lf(src) - .split('\n') - .map((ln) => ln.replace(/\t/g, ' ')); - // grab 2 lines before, and 3 lines after focused line - const visibleLines = []; - for (let n = -2; n <= 2; n++) { - if (lines[loc.line + n]) visibleLines.push(loc.line + n); - } - // figure out gutter width - let gutterWidth = 0; - for (const lineNo of visibleLines) { - let w = `> ${lineNo}`; - if (w.length > gutterWidth) gutterWidth = w.length; - } - // print lines - let output = ''; - for (const lineNo of visibleLines) { - const isFocusedLine = lineNo === loc.line - 1; - output += isFocusedLine ? '> ' : ' '; - output += `${lineNo + 1} | ${lines[lineNo]}\n`; - if (isFocusedLine) - output += `${Array.from({ length: gutterWidth }).join(' ')} | ${Array.from({ - length: loc.column, - }).join(' ')}^\n`; - } - return output; -} - export function resolveDependency(dep: string, projectRoot: URL) { const resolved = resolve.sync(dep, { basedir: fileURLToPath(projectRoot), @@ -256,14 +223,3 @@ export function resolvePath(specifier: string, importer: string) { return specifier; } } - -export const AggregateError = - typeof (globalThis as any).AggregateError !== 'undefined' - ? (globalThis as any).AggregateError - : class extends Error { - errors: Array = []; - constructor(errors: Iterable, message?: string | undefined) { - super(message); - this.errors = Array.from(errors); - } - }; diff --git a/packages/astro/src/events/error.ts b/packages/astro/src/events/error.ts index fa9778b44..f0425098c 100644 --- a/packages/astro/src/events/error.ts +++ b/packages/astro/src/events/error.ts @@ -1,5 +1,5 @@ import { ZodError } from 'zod'; -import { AstroErrorCodes, ErrorWithMetadata } from '../core/errors.js'; +import { AstroErrorCodes, ErrorWithMetadata } from '../core/errors/index.js'; const EVENT_ERROR = 'ASTRO_CLI_ERROR'; diff --git a/packages/astro/src/vite-plugin-astro-server/index.ts b/packages/astro/src/vite-plugin-astro-server/index.ts index 5ab23e809..98afcef3f 100644 --- a/packages/astro/src/vite-plugin-astro-server/index.ts +++ b/packages/astro/src/vite-plugin-astro-server/index.ts @@ -7,12 +7,6 @@ import { DevelopmentEnvironment, SSROptions } from '../core/render/dev/index'; import { Readable } from 'stream'; import { attachToResponse, getSetCookiesFromResponse } from '../core/cookies/index.js'; import { call as callEndpoint } from '../core/endpoint/dev/index.js'; -import { - collectErrorMetadata, - ErrorWithMetadata, - fixViteErrorMessage, - getViteErrorPayload, -} from '../core/errors.js'; import { error, info, LogOptions, warn } from '../core/logger/core.js'; import * as msg from '../core/messages.js'; import { appendForwardSlash } from '../core/path.js'; @@ -22,6 +16,8 @@ import { createRequest } from '../core/request.js'; import { createRouteManifest, matchAllRoutes } from '../core/routing/index.js'; import { resolvePages } from '../core/util.js'; import notFoundTemplate, { subpathNotUsedTemplate } from '../template/4xx.js'; +import { collectErrorMetadata, getViteErrorPayload } from '../core/errors/dev/index.js'; +import type { ErrorWithMetadata } from '../core/errors/index.js'; interface AstroPluginOptions { settings: AstroSettings; @@ -282,15 +278,14 @@ async function handleRequest( body = Buffer.concat(bytes); } - let filePath: URL | undefined; try { const matchedRoute = await matchRoute(pathname, env, manifest); - filePath = matchedRoute?.filePath; - return await handleRoute(matchedRoute, url, pathname, body, origin, env, manifest, req, res); } catch (_err) { - const err = fixViteErrorMessage(_err, viteServer, filePath); - const errorWithMetadata = collectErrorMetadata(err); + // This is our last line of defense regarding errors where we still might have some information about the request + // Our error should already be complete, but let's try to add a bit more through some guesswork + const errorWithMetadata = collectErrorMetadata(_err); + error(env.logging, null, msg.formatErrorMessage(errorWithMetadata)); handle500Response(viteServer, origin, req, res, errorWithMetadata); } @@ -383,7 +378,7 @@ async function handleRoute( await writeWebResponse(res, result.response); } else { let contentType = 'text/plain'; - // Dynamic routes don’t include `route.pathname`, so synthesise a path for these (e.g. 'src/pages/[slug].svg') + // Dynamic routes don’t include `route.pathname`, so synthesize a path for these (e.g. 'src/pages/[slug].svg') const filepath = route.pathname || route.segments.map((segment) => segment.map((p) => p.content).join('')).join('/'); diff --git a/packages/astro/src/vite-plugin-astro/index.ts b/packages/astro/src/vite-plugin-astro/index.ts index 7d4c5d762..5c300b9e3 100644 --- a/packages/astro/src/vite-plugin-astro/index.ts +++ b/packages/astro/src/vite-plugin-astro/index.ts @@ -311,7 +311,7 @@ export default function astro({ settings, logging }: AstroPluginOptions): vite.P } // improve compiler errors - if (err.stack.includes('wasm-function')) { + if (err.stack && err.stack.includes('wasm-function')) { const search = new URLSearchParams({ labels: 'compiler', title: '🐛 BUG: `@astrojs/compiler` panic', diff --git a/packages/astro/src/vite-plugin-markdown-legacy/index.ts b/packages/astro/src/vite-plugin-markdown-legacy/index.ts index 0feb3a517..e016d46a0 100644 --- a/packages/astro/src/vite-plugin-markdown-legacy/index.ts +++ b/packages/astro/src/vite-plugin-markdown-legacy/index.ts @@ -8,7 +8,7 @@ import type { Plugin, ViteDevServer } from 'vite'; import type { AstroSettings } from '../@types/astro'; import { pagesVirtualModuleId } from '../core/app/index.js'; import { cachedCompilation, CompileProps } from '../core/compile/index.js'; -import { collectErrorMetadata } from '../core/errors.js'; +import { AstroErrorCodes, MarkdownError } from '../core/errors/index.js'; import type { LogOptions } from '../core/logger/core.js'; import { isMarkdownFile } from '../core/util.js'; import type { PluginMetadata as AstroPluginMetadata } from '../vite-plugin-astro/types'; @@ -30,9 +30,28 @@ const MARKDOWN_CONTENT_FLAG = '?content'; function safeMatter(source: string, id: string) { try { return matter(source); - } catch (e) { - (e as any).id = id; - throw collectErrorMetadata(e); + } catch (err: any) { + const markdownError = new MarkdownError({ + errorCode: AstroErrorCodes.GenericMarkdownError, + message: err.message, + stack: err.stack, + location: { + file: id, + }, + }); + + if (err.name === 'YAMLException') { + markdownError.setErrorCode(AstroErrorCodes.MarkdownFrontmatterParseError); + markdownError.setLocation({ + file: id, + line: err.mark.line, + column: err.mark.column, + }); + + markdownError.setMessage(err.reason); + } + + throw markdownError; } } diff --git a/packages/astro/src/vite-plugin-markdown/index.ts b/packages/astro/src/vite-plugin-markdown/index.ts index 4c9055e6b..6a4e62b9f 100644 --- a/packages/astro/src/vite-plugin-markdown/index.ts +++ b/packages/astro/src/vite-plugin-markdown/index.ts @@ -5,7 +5,7 @@ import { fileURLToPath } from 'node:url'; import type { Plugin } from 'vite'; import { normalizePath } from 'vite'; import type { AstroSettings } from '../@types/astro'; -import { collectErrorMetadata } from '../core/errors.js'; +import { AstroErrorCodes, MarkdownError } from '../core/errors/index.js'; import type { LogOptions } from '../core/logger/core.js'; import { warn } from '../core/logger/core.js'; import { isMarkdownFile } from '../core/util.js'; @@ -20,9 +20,28 @@ interface AstroPluginOptions { function safeMatter(source: string, id: string) { try { return matter(source); - } catch (e) { - (e as any).id = id; - throw collectErrorMetadata(e); + } catch (err: any) { + const markdownError = new MarkdownError({ + errorCode: AstroErrorCodes.GenericMarkdownError, + message: err.message, + stack: err.stack, + location: { + file: id, + }, + }); + + if (err.name === 'YAMLException') { + markdownError.setErrorCode(AstroErrorCodes.MarkdownFrontmatterParseError); + markdownError.setLocation({ + file: id, + line: err.mark.line, + column: err.mark.column, + }); + + markdownError.setMessage(err.reason); + } + + throw markdownError; } } diff --git a/packages/astro/src/vite-style-transform/style-transform.ts b/packages/astro/src/vite-style-transform/style-transform.ts index 6c63158af..ffa3ea57d 100644 --- a/packages/astro/src/vite-style-transform/style-transform.ts +++ b/packages/astro/src/vite-style-transform/style-transform.ts @@ -3,7 +3,11 @@ import { fileURLToPath } from 'url'; import type { TransformStyle } from '../core/compile/index'; import { createTransformStyleWithViteFn, TransformStyleWithVite } from './transform-with-vite.js'; +import { readFileSync } from 'fs'; import type * as vite from 'vite'; +import { AstroErrorCodes } from '../core/errors/codes.js'; +import { CSSError } from '../core/errors/errors.js'; +import { positionAt } from '../core/errors/utils.js'; export type ViteStyleTransformer = { viteDevServer?: vite.ViteDevServer; @@ -35,13 +39,67 @@ export function createTransformStyles( const normalizedID = getNormalizedIDForPostCSS(filename); return async function (styleSource, lang) { - const result = await viteStyleTransformer.transformStyleWithVite.call(pluginContext, { - id: normalizedID, - source: styleSource, - lang, - ssr, - viteDevServer: viteStyleTransformer.viteDevServer, - }); + let result: any; + try { + result = await viteStyleTransformer.transformStyleWithVite.call(pluginContext, { + id: normalizedID, + source: styleSource, + lang, + ssr, + viteDevServer: viteStyleTransformer.viteDevServer, + }); + } catch (err: any) { + const fileContent = readFileSync(filename).toString(); + const styleTagBeginning = fileContent.indexOf(err.input?.source ?? err.code); + + // PostCSS Syntax Error + if (err.name === 'CssSyntaxError') { + const errorLine = positionAt(styleTagBeginning, fileContent).line + (err.line ?? 0); + + // Vite will handle creating the frame for us with proper line numbers, no need to create one + + throw new CSSError({ + errorCode: AstroErrorCodes.CssSyntaxError, + message: err.reason, + location: { + file: filename, + line: errorLine, + column: err.column, + }, + }); + } + + // Some CSS processor will return a line and a column, so let's try to show a pretty error + if (err.line && err.column) { + const errorLine = positionAt(styleTagBeginning, fileContent).line + (err.line ?? 0); + + throw new CSSError({ + errorCode: AstroErrorCodes.CssUnknownError, + message: err.message, + location: { + file: filename, + line: errorLine, + column: err.column, + }, + frame: err.frame, + }); + } + + // For other errors we'll just point to the beginning of the style tag + const errorPosition = positionAt(styleTagBeginning, fileContent); + errorPosition.line += 1; + + throw new CSSError({ + errorCode: AstroErrorCodes.CssUnknownError, + message: err.message, + location: { + file: filename, + line: errorPosition.line, + column: 0, + }, + frame: err.frame, + }); + } return result; }; diff --git a/packages/astro/test/events.test.js b/packages/astro/test/events.test.js index f447d5d80..7e355d6aa 100644 --- a/packages/astro/test/events.test.js +++ b/packages/astro/test/events.test.js @@ -1,5 +1,5 @@ import { expect } from 'chai'; -import { AstroErrorCodes } from '../dist/core/errors.js'; +import { AstroErrorCodes } from '../dist/core/errors/codes.js'; import * as events from '../dist/events/index.js'; describe('Events', () => { diff --git a/packages/astro/test/units/compile/invalid-css.test.js b/packages/astro/test/units/compile/invalid-css.test.js index 00d4fb7f6..0ea6f77a1 100644 --- a/packages/astro/test/units/compile/invalid-css.test.js +++ b/packages/astro/test/units/compile/invalid-css.test.js @@ -1,6 +1,6 @@ import { expect } from 'chai'; import { cachedCompilation } from '../../../dist/core/compile/index.js'; -import { AggregateError } from '../../../dist/core/util.js'; +import { AggregateError } from '../../../dist/core/errors/index.js'; describe('astro/src/core/compile', () => { describe('Invalid CSS', () => {