Refactor error handling (#5206)

* Refactor error handling

* Fix import path in test

* Revert "Fix import path in test"

This reverts commit 5ca34f3c09.

* Fix import path in test

* Fix AggregateErrors actually not being.. an aggregration of errors

* Fix missing info in Vite enhanced error

* Conserve original error name if we have one

* Workaround compiler issue

* GitHub action please

* Update E2E test

* Wrap ssrFixStacktrace in try/catch

* Refactor Vite/Node methods out of the general index.ts

* Fix missing import

* Add changeset
This commit is contained in:
Erika 2022-10-27 14:37:53 -03:00 committed by GitHub
parent 3c1af36f5e
commit d64d5b9b52
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
26 changed files with 744 additions and 307 deletions

View file

@ -0,0 +1,5 @@
---
'astro': patch
---
Improve error messages related to CSS and compiler errors

View file

@ -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

View file

@ -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';

View file

@ -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);
}
}

View file

@ -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<string, CompileResult>;
@ -30,7 +31,7 @@ async function compile({
transformStyle,
}: CompileProps): Promise<CompileResult> {
let cssDeps = new Set<string>();
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 });
}
}
});

View file

@ -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 {

View file

@ -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,
},
};
}

View file

@ -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,
}

View file

@ -0,0 +1,2 @@
export { collectErrorMetadata } from './utils.js';
export { createCustomViteLogger, enhanceViteSSRError, getViteErrorPayload } from './vite.js';

View file

@ -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;
}

View file

@ -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,
},
};
}

View file

@ -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;
};
}

View file

@ -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';

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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:')}`);

View file

@ -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<SSROptions, 'env' | 'filePath'>): Promise<ComponentPreload> {
// 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 {

View file

@ -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<any> = [];
constructor(errors: Iterable<any>, message?: string | undefined) {
super(message);
this.errors = Array.from(errors);
}
};

View file

@ -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';

View file

@ -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 dont include `route.pathname`, so synthesise a path for these (e.g. 'src/pages/[slug].svg')
// Dynamic routes dont 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('/');

View file

@ -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',

View file

@ -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;
}
}

View file

@ -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;
}
}

View file

@ -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;
};

View file

@ -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', () => {

View file

@ -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', () => {