Migrate error messages to new format (and misc error improvements) (#5316)

* Migrate messages to errors data file

* Move errors to new 'error database' for easier improvements

* Fix tests

* Add changeset

* Remove unnecessary console.log

* Apply suggestions from code review

Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>

* Update packages/astro/src/core/errors/errors-data.ts

Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>

* Misc punctuations fixes and additions to README

Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>
This commit is contained in:
Erika 2022-11-09 11:53:42 -04:00 committed by GitHub
parent faa01cec73
commit a780f2595d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
38 changed files with 797 additions and 382 deletions

View file

@ -0,0 +1,5 @@
---
'astro': patch
---
Improved error messages descriptions and hints to be more informative

View file

@ -1,5 +1,5 @@
import { expect } from '@playwright/test';
import { testFactory, getErrorOverlayMessage } from './test-utils.js';
import { getErrorOverlayMessage, testFactory } from './test-utils.js';
const test = testFactory({ root: './fixtures/errors/' });
@ -38,7 +38,9 @@ test.describe('Error display', () => {
await page.goto(astro.resolveUrl('/import-not-found'));
const message = await getErrorOverlayMessage(page);
expect(message).toMatch('Could not import "../abc.astro"');
expect(message).toMatch(
'Could not import "../abc.astro".\n\nThis is often caused by a typo in the import path. Please make sure the file exists.'
);
await Promise.all([
// Wait for page reload

View file

@ -123,7 +123,6 @@
"debug": "^4.3.4",
"deepmerge-ts": "^4.2.2",
"diff": "^5.1.0",
"eol": "^0.9.1",
"es-module-lexer": "^0.10.5",
"esbuild": "^0.14.43",
"execa": "^6.1.0",
@ -199,7 +198,8 @@
"remark-code-titles": "^0.1.2",
"sass": "^1.52.2",
"srcset-parse": "^1.1.0",
"unified": "^10.1.2"
"unified": "^10.1.2",
"eol": "^0.9.1"
},
"engines": {
"node": "^14.18.0 || >=16.12.0",

View file

@ -1,6 +1,5 @@
/* eslint-disable no-console */
import * as colors from 'kleur/colors';
import { pathToFileURL } from 'url';
import type { Arguments as Flags } from 'yargs-parser';
import yargs from 'yargs-parser';
import { z } from 'zod';
@ -94,9 +93,7 @@ async function handleConfigError(
if (path) {
error(logging, 'astro', `Unable to load ${colors.bold(path)}\n`);
}
console.error(
formatErrorMessage(collectErrorMetadata(e, path ? pathToFileURL(path) : undefined)) + '\n'
);
console.error(formatErrorMessage(collectErrorMetadata(e)) + '\n');
}
}

View file

@ -13,7 +13,6 @@ import {
runHookConfigSetup,
} from '../../integrations/index.js';
import { createVite } from '../create-vite.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 +168,7 @@ class AstroBuilder {
try {
await this.build(setupData);
} catch (_err) {
throw enhanceViteSSRError(_err as Error);
throw _err;
}
}

View file

@ -10,6 +10,7 @@ import { prependForwardSlash } from '../../core/path.js';
import { isModeServerWithNoAdapter } from '../../core/util.js';
import { runHookBuildSetup } from '../../integrations/index.js';
import { PAGE_SCRIPT_ID } from '../../vite-plugin-scripts/index.js';
import { AstroError, AstroErrorData } from '../errors/index.js';
import { info } from '../logger/core.js';
import { getOutDirWithinCwd } from './common.js';
import { generatePages } from './generate.js';
@ -26,19 +27,9 @@ import { injectManifest, vitePluginSSR } from './vite-plugin-ssr.js';
export async function staticBuild(opts: StaticBuildOptions) {
const { allPages, settings } = opts;
// Verify this app is buildable.
// Make sure we have an adapter before building
if (isModeServerWithNoAdapter(opts.settings)) {
throw new Error(`Cannot use \`output: 'server'\` without an adapter.
Install and configure the appropriate server adapter for your final deployment.
Learn more: https://docs.astro.build/en/guides/server-side-rendering/
// Example: astro.config.js
import netlify from '@astrojs/netlify';
export default {
output: 'server',
adapter: netlify(),
}
`);
throw new AstroError(AstroErrorData.NoAdapterInstalled);
}
// The pages to be built for rendering purposes.

View file

@ -3,8 +3,8 @@ import type { ResolvedConfig } from 'vite';
import type { AstroConfig } from '../../@types/astro';
import { transform } from '@astrojs/compiler';
import { AstroErrorCodes } from '../errors/codes.js';
import { AggregateError, AstroError, CompilerError } from '../errors/errors.js';
import { AstroErrorData } from '../errors/index.js';
import { prependForwardSlash } from '../path.js';
import { resolvePath, viteID } from '../util.js';
import { createStylePreprocessor } from './style.js';
@ -61,7 +61,7 @@ async function compile({
// 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,
...AstroErrorData.UnknownCompilerError,
message: err.message ?? 'Unknown compiler error',
stack: err.stack,
location: {
@ -70,22 +70,18 @@ async function compile({
});
})
.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
);
const compilerError = result.diagnostics.find((diag) => diag.severity === 1);
if (compilerError) {
throw new CompilerError({
errorCode: compilerError.code,
code: compilerError.code,
message: compilerError.text,
location: {
line: compilerError.location.line,
column: compilerError.location.column,
file: compilerError.location.file,
},
hint: compilerError.hint ? compilerError.hint : undefined,
hint: compilerError.hint,
});
}
@ -94,8 +90,8 @@ async function compile({
return result;
case 1: {
let error = cssTransformErrors[0];
if (!error.errorCode) {
error.errorCode = AstroErrorCodes.UnknownCompilerCSSError;
if (!error.code) {
error.code = AstroErrorData.UnknownCSSError.code;
}
throw cssTransformErrors[0];

View file

@ -1,9 +1,7 @@
import type { TransformOptions } from '@astrojs/compiler';
import fs from 'fs';
import { preprocessCSS, ResolvedConfig } from 'vite';
import { AstroErrorCodes } from '../errors/codes.js';
import { CSSError } from '../errors/errors.js';
import { positionAt } from '../errors/index.js';
import { AstroErrorData, CSSError, positionAt } from '../errors/index.js';
export function createStylePreprocessor({
filename,
@ -57,7 +55,7 @@ function enhanceCSSError(err: any, filename: string) {
// Vite will handle creating the frame for us with proper line numbers, no need to create one
return new CSSError({
errorCode: AstroErrorCodes.CssSyntaxError,
...AstroErrorData.CSSSyntaxError,
message: err.reason,
location: {
file: filename,
@ -72,7 +70,7 @@ function enhanceCSSError(err: any, filename: string) {
const errorLine = positionAt(styleTagBeginning, fileContent).line + (err.line ?? 0);
return new CSSError({
errorCode: AstroErrorCodes.CssUnknownError,
...AstroErrorData.UnknownCSSError,
message: err.message,
location: {
file: filename,
@ -88,7 +86,7 @@ function enhanceCSSError(err: any, filename: string) {
errorPosition.line += 1;
return new CSSError({
errorCode: AstroErrorCodes.CssUnknownError,
code: AstroErrorData.UnknownCSSError.code,
message: err.message,
location: {
file: filename,

View file

@ -12,6 +12,7 @@ import { mergeConfig as mergeViteConfig } from 'vite';
import { LogOptions } from '../logger/core.js';
import { arraify, isObject, isURL } from '../util.js';
import { createRelativeSchema } from './schema.js';
import { AstroError, AstroErrorData } from '../errors/index.js';
load.use([loadTypeScript]);
@ -76,9 +77,10 @@ export async function validateConfig(
}
}
if (legacyConfigKey) {
throw new Error(
`Legacy configuration detected: "${legacyConfigKey}".\nPlease update your configuration to the new format!\nSee https://astro.build/config for more information.`
);
throw new AstroError({
...AstroErrorData.ConfigLegacyKey,
message: AstroErrorData.ConfigLegacyKey.message(legacyConfigKey),
});
}
/* eslint-enable no-console */
@ -171,7 +173,10 @@ export async function resolveConfigPath(
return configPath;
} catch (e) {
if (e instanceof ProloadError && flags.config) {
throw new Error(`Unable to resolve --config "${flags.config}"! Does the file exist?`);
throw new AstroError({
...AstroErrorData.ConfigNotFound,
message: AstroErrorData.ConfigNotFound.message(flags.config),
});
}
throw e;
}
@ -251,7 +256,10 @@ async function tryLoadConfig(
return config as TryLoadConfigResult;
} catch (e) {
if (e instanceof ProloadError && flags.config) {
throw new Error(`Unable to resolve --config "${flags.config}"! Does the file exist?`);
throw new AstroError({
...AstroErrorData.ConfigNotFound,
message: AstroErrorData.ConfigNotFound.message(flags.config),
});
}
const configPath = await resolveConfigPath(configOptions);

View file

@ -5,6 +5,7 @@ import { renderEndpoint } from '../../runtime/server/index.js';
import { ASTRO_VERSION } from '../constants.js';
import { AstroCookies, attachToResponse } from '../cookies/index.js';
import { getParamsAndProps, GetParamsAndPropsError } from '../render/core.js';
import { AstroError, AstroErrorData } from '../errors/index.js';
const clientAddressSymbol = Symbol.for('astro.clientAddress');
@ -52,13 +53,12 @@ function createAPIContext({
get clientAddress() {
if (!(clientAddressSymbol in request)) {
if (adapterName) {
throw new Error(
`clientAddress is not available in the ${adapterName} adapter. File an issue with the adapter to add support.`
);
throw new AstroError({
...AstroErrorData.SSRClientAddressNotAvailableInAdapter,
message: AstroErrorData.SSRClientAddressNotAvailableInAdapter.message(adapterName),
});
} else {
throw new Error(
`clientAddress is not available in your environment. Ensure that you are using an SSR adapter that supports this feature.`
);
throw new AstroError(AstroErrorData.StaticClientAddressNotAvailable);
}
}
@ -82,9 +82,13 @@ export async function call(
});
if (paramsAndPropsResp === GetParamsAndPropsError.NoMatchingStaticPath) {
throw new Error(
`[getStaticPath] route pattern matched, but no matching static path found. (${ctx.pathname})`
);
throw new AstroError({
...AstroErrorData.NoMatchingStaticPathFound,
message: AstroErrorData.NoMatchingStaticPathFound.message(ctx.pathname),
hint: ctx.route?.component
? AstroErrorData.NoMatchingStaticPathFound.hint([ctx.route?.component])
: '',
});
}
const [params, props] = paramsAndPropsResp;
@ -120,8 +124,6 @@ function isRedirect(statusCode: number) {
export function throwIfRedirectNotAllowed(response: Response, config: AstroConfig) {
if (config.output !== 'server' && isRedirect(response.status)) {
throw new Error(
`Redirects are only available when using output: 'server'. Update your Astro config if you need SSR features.`
);
throw new AstroError(AstroErrorData.StaticRedirectNotAllowed);
}
}

View file

@ -0,0 +1,57 @@
# Errors
> Interested in the technical details? See the comments in [errors-data.ts.](./errors-data.ts)
## Writing error messages for Astro
### Tips
**Choosing an Error Code**
Choose any available error code in the appropriate range:
- 01xxx and 02xxx are reserved for compiler errors and warnings respectively
- 03xxx: Astro errors (your error most likely goes here!)
- 04xxx: CSS errors
- 05xxx: Vite errors
- 06xxx: Markdown errors
- 07xxx: Configuration errors
- 07xxx-98xxx <- Need to add a category? Add it here!
- 99xxx: Catch-alls for unknown errors
As long as it is unique, the exact error code used is unimportant. For example, error 5005 and error 5006 don't necessarily have to be related, or follow any logical pattern.
Users are not reading codes sequentially. They're much more likely to directly land on the error or search for a specific code.
If you are unsure about which error code to choose, ask [Erika](https://github.com/Princesseuh)!
**Error Code Format**
- Begin with **what happened** and **why** (ex: `Could not use {feature} because Server-side Rendering is not enabled`)
- Then, **describe the action the user should take** (ex: `Update your Astro config with `output: 'server'` to enable Server-side Rendering.`)
- A `hint` can be used for any additional info that might help the user (ex: a link to the documentation, or a common cause)
**Error Code Writing Style**
- Technical jargon is mostly okay! But, most abbreviations should be avoided. If a developer is unfamiliar with a technical term, spelling it out in full allows them to look it up on the web more easily.
- Describe the what, why and action to take from the user's perspective. Assume they don't know Astro internals, and care only about how Astro is _used_ (ex: `You are missing...` vs `Astro/file cannot find...`)
- Avoid using cutesy language (ex: Oops!). This tone minimizes the significance of the error, which _is_ important to the developer. The developer may be frustrated and your error message shouldn't be making jokes about their struggles. Only include words and phrases that help the developer **interpret the error** and **fix the problem**.
### CLI specifics:
- If the error happened **during an action that changes the state of the project** (ex: editing configuration, creating files), the error should **reassure the user** about the state of their project (ex: "Failed to update configuration. Your project has been restored to its previous state.")
- If an "error" happened because of a conscious user action (ex: pressing CTRL+C during a choice), it is okay to add more personality (ex: "Operation cancelled. See you later, astronaut!"). Do keep in mind the previous point however (ex: "Operation cancelled. No worries, your project folder has already been created")
### Shape
- **Error codes and names are permanent**, and should never be changed, nor deleted. Users should always be able to find an error by searching, and this ensures a matching result. When an error is no longer relevant, it should be deprecated, not removed.
- Contextual information may be used to enhance the message or the hint. However, the error code itself should not be included in the message as it will already be shown as part of the the error.
- Do not prefix `message` and `hint` with descriptive words such as "Error:" or "Hint:" as it may lead to duplicated labels in the UI / CLI.
### Always remember
Error are a reactive strategy. They are the last line of defense against a mistake.
Before adding a new error message, ask yourself, "Was there a way this situation could've been avoided in the first place?" (docs, editor tooling etc).
**If you can prevent the error, you don't need an error message!**
## Additional resources on writing good error messages
- [When life gives you lemons, write better error messages](https://wix-ux.com/when-life-gives-you-lemons-write-better-error-messages-46c5223e1a2f)
- [RustConf 2020 - Bending the Curve: A Personal Tutor at Your Fingertips by Esteban Kuber](https://www.youtube.com/watch?v=Z6X7Ada0ugE) (part on error messages starts around 19:17)

View file

@ -1,24 +0,0 @@
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

@ -1,9 +1,12 @@
import type { BuildResult } from 'esbuild';
import * as fs from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import stripAnsi from 'strip-ansi';
import type { SSRError } from '../../../@types/astro.js';
import { AggregateError, ErrorWithMetadata } from '../errors.js';
import { codeFrame } from '../printer.js';
import { collectInfoFromStacktrace } from '../utils.js';
import { normalizeLF } 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.`,
@ -14,7 +17,7 @@ export const incompatPackageExp = new RegExp(`(${Object.keys(incompatiblePackage
* 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 {
export function collectErrorMetadata(e: any, rootFolder?: URL | undefined): ErrorWithMetadata {
const err = AggregateError.is(e) ? (e.errors as SSRError[]) : [e as SSRError];
err.forEach((error) => {
@ -22,6 +25,11 @@ export function collectErrorMetadata(e: any, filePath?: URL): ErrorWithMetadata
error = collectInfoFromStacktrace(e);
}
if (error.loc?.file && rootFolder && !error.loc.file.startsWith('/')) {
error.loc.file = join(fileURLToPath(rootFolder), error.loc.file);
}
// 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 {
@ -32,21 +40,33 @@ export function collectErrorMetadata(e: any, filePath?: URL): ErrorWithMetadata
}
// Generic error (probably from Vite, and already formatted)
if (!error.hint) {
error.hint = generateHint(e, filePath);
}
error.hint = generateHint(e);
});
// 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;
const { location, pluginName, text } = buildError;
// ESBuild can give us a slightly better error message than the one in the error, so let's use it
err[i].message = text;
if (location) {
err[i].loc = { file: location.file, line: location.line, column: location.column };
err[i].id = err[0].id || location?.file;
}
// Vite adds the error message to the frame for ESBuild errors, we don't want that
if (err[i].frame) {
const errorLines = err[i].frame?.trim().split('\n');
if (errorLines) {
err[i].frame = !/^\d/.test(errorLines[0])
? errorLines?.slice(1).join('\n')
: err[i].frame;
}
}
const possibleFilePath = err[i].pluginCode || err[i].id || location?.file;
if (possibleFilePath && !err[i].frame) {
try {
@ -61,7 +81,7 @@ export function collectErrorMetadata(e: any, filePath?: URL): ErrorWithMetadata
err[i].plugin = pluginName;
}
err[i].hint = generateHint(err[0], filePath);
err[i].hint = generateHint(err[0]);
});
}
@ -69,14 +89,21 @@ export function collectErrorMetadata(e: any, filePath?: URL): ErrorWithMetadata
return err[0];
}
function generateHint(err: ErrorWithMetadata, filePath?: URL): string | undefined {
function generateHint(err: ErrorWithMetadata): 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 if (err.toString().includes('document')) {
const hint = `Browser APIs are not available on the server.
${
err.loc?.file?.endsWith('.astro')
? 'Move your code to a <script> tag outside of the frontmatter, so the code runs on the client'
: 'If the code is in a framework component, try to access these objects after rendering using lifecycle methods or use a `client:only` directive to make the component exclusively run on the client'
}
See https://docs.astro.build/en/guides/troubleshooting/#document-or-window-is-not-defined for more information.
`;
return hint;
} else {
const res = incompatPackageExp.exec(err.stack);
if (res) {
@ -84,5 +111,61 @@ function generateHint(err: ErrorWithMetadata, filePath?: URL): string | undefine
return incompatiblePackages[key];
}
}
return undefined;
return err.hint;
}
function collectInfoFromStacktrace(error: SSRError): SSRError {
if (!error.stack) return error;
// normalize error stack line-endings to \n
error.stack = normalizeLF(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+/, '');
let file = source?.replace(/(:[0-9]+)/g, '');
const location = /:([0-9]+):([0-9]+)/g.exec(source!) ?? [];
const line = location[1];
const column = location[2];
if (file && line && column) {
try {
file = fileURLToPath(file);
} catch {}
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');
}

View file

@ -2,8 +2,9 @@ import * as fs from 'fs';
import { fileURLToPath } from 'url';
import { createLogger, type ErrorPayload, type Logger, type LogLevel } from 'vite';
import type { ModuleLoader } from '../../module-loader/index.js';
import { AstroErrorCodes } from '../codes.js';
import { AstroError, type ErrorWithMetadata } from '../errors.js';
import { AstroErrorData } from '../errors-data.js';
import { type ErrorWithMetadata } from '../errors.js';
import { createSafeError } from '../utils.js';
import { incompatPackageExp } from './utils.js';
/**
@ -22,55 +23,71 @@ export function createCustomViteLogger(logLevel: LogLevel): Logger {
return logger;
}
export function enhanceViteSSRError(
error: Error,
filePath?: URL,
loader?: ModuleLoader
): AstroError {
export function enhanceViteSSRError(error: unknown, filePath?: URL, loader?: ModuleLoader): Error {
// NOTE: We don't know where the error that's coming here comes from, so we need to be defensive regarding what we do
// to it to make sure we keep as much information as possible. It's very possible that we receive an error that does not
// follow any kind of standard formats (ex: a number, a string etc)
const safeError = createSafeError(error) as ErrorWithMetadata;
// Vite will give you better stacktraces, using sourcemaps.
if (loader) {
try {
loader.fixStacktrace(error);
loader.fixStacktrace(safeError as 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,
});
if (filePath) {
const path = fileURLToPath(filePath);
const content = fs.readFileSync(path).toString();
const lns = content.split('\n');
// 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);
// 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 (/failed to load module for ssr:/.test(safeError.message)) {
const importName = safeError.message.split('for ssr:').at(1)?.trim();
if (importName) {
safeError.message = AstroErrorData.FailedToLoadModuleSSR.message(importName);
safeError.hint = AstroErrorData.FailedToLoadModuleSSR.hint;
safeError.code = AstroErrorData.FailedToLoadModuleSSR.code;
const line = lns.findIndex((ln) => ln.includes(importName));
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);
if (line !== -1) {
const column = lns[line]?.indexOf(importName);
safeError.loc = {
file: path,
line: line + 1,
column,
};
}
}
}
newError.setLocation({
file: path,
line: line + 1,
column,
});
// Since Astro.glob is a wrapper around Vite's import.meta.glob, errors don't show accurate information, let's fix that
if (/Invalid glob/.test(safeError.message)) {
const globPattern = safeError.message.match(/glob: "(.+)" \(/)?.[1];
if (globPattern) {
safeError.message = AstroErrorData.InvalidGlob.message(globPattern);
safeError.hint = AstroErrorData.InvalidGlob.hint;
safeError.code = AstroErrorData.InvalidGlob.code;
const line = lns.findIndex((ln) => ln.includes(globPattern));
if (line !== -1) {
const column = lns[line]?.indexOf(globPattern);
safeError.loc = {
file: path,
line: line + 1,
column,
};
}
}
}
}
return newError;
return safeError;
}
/**

View file

@ -0,0 +1,199 @@
// BEFORE ADDING AN ERROR: Please look at the README.md in this folder for general guidelines on writing error messages
// Additionally, this code, much like `@types/astro.ts`, is used to generate documentation, so make sure to pass
// your changes by our wonderful docs team before merging!
interface ErrorData {
code: number;
message?: string | ((...params: any) => string);
hint?: string | ((...params: any) => string);
}
// TODO: Replace with `satisfies` once TS 4.9 is out
const defineErrors = <T extends Record<string, ErrorData>>(errs: T) => errs;
export const AstroErrorData = defineErrors({
UnknownCompilerError: {
code: 1000,
},
// 1xxx and 2xxx codes are reserved for compiler errors and warnings respectively
StaticRedirectNotAllowed: {
code: 3001,
message:
"Redirects are only available when using output: 'server'. Update your Astro config if you need SSR features.",
hint: 'See https://docs.astro.build/en/guides/server-side-rendering/#enabling-ssr-in-your-project for more information on how to enable SSR.',
},
SSRClientAddressNotAvailableInAdapter: {
code: 3002,
message: (adapterName: string) =>
`Astro.clientAddress is not available in the ${adapterName} adapter. File an issue with the adapter to add support.`,
},
StaticClientAddressNotAvailable: {
code: 3003,
message:
"Astro.clientAddress is only available when using output: 'server'. Update your Astro config if you need SSR features.",
hint: 'See https://docs.astro.build/en/guides/server-side-rendering/#enabling-ssr-in-your-project for more information on how to enable SSR.',
},
NoMatchingStaticPathFound: {
code: 3004,
message: (pathName: string) =>
`A getStaticPaths route pattern was matched, but no matching static path was found for requested path ${pathName}.`,
hint: (possibleRoutes: string[]) =>
`Possible dynamic routes being matched: ${possibleRoutes.join(', ')}.`,
},
OnlyResponseCanBeReturned: {
code: 3005,
message: (route: string | undefined, returnedValue: string) =>
`Route ${
route ? route : ''
} returned a ${returnedValue}. Only a Response can be returned from Astro files.`,
hint: 'See https://docs.astro.build/en/guides/server-side-rendering/#response for more information.',
},
MissingMediaQueryDirective: {
code: 3006,
message: (componentName: string) =>
`Media query not provided for "client:media" directive. A media query similar to <${componentName} client:media="(max-width: 600px)" /> must be provided`,
},
NoMatchingRenderer: {
code: 3007,
message: (
componentName: string,
componentExtension: string | undefined,
plural: boolean,
validRenderersCount: number
) =>
`Unable to render ${componentName}!
${
validRenderersCount > 0
? `There ${plural ? 'are' : 'is'} ${validRenderersCount} renderer${
plural ? 's' : ''
} configured in your \`astro.config.mjs\` file,
but ${plural ? 'none were' : 'it was not'} able to server-side render ${componentName}.`
: `No valid renderer was found ${
componentExtension
? `for the .${componentExtension} file extension.`
: `for this file extension.`
}`
}`,
hint: (probableRenderers: string) =>
`Did you mean to enable the ${probableRenderers} integration?\n\nSee https://docs.astro.build/en/core-concepts/framework-components/ for more information on how to install and configure integrations.`,
},
NoClientEntrypoint: {
code: 3008,
message: (componentName: string, clientDirective: string, rendererName: string) =>
`${componentName} component has a \`client:${clientDirective}\` directive, but no client entrypoint was provided by ${rendererName}!`,
hint: 'See https://docs.astro.build/en/reference/integrations-reference/#addrenderer-option for more information on how to configure your renderer.',
},
NoClientOnlyHint: {
code: 3009,
message: (componentName: string) =>
`Unable to render ${componentName}! When using the \`client:only\` hydration strategy, Astro needs a hint to use the correct renderer.`,
hint: (probableRenderers: string) =>
`Did you mean to pass client:only="${probableRenderers}"? See https://docs.astro.build/en/reference/directives-reference/#clientonly for more information on client:only`,
},
InvalidStaticPathParam: {
code: 3010,
message: (paramType) =>
`Invalid params given to getStaticPaths path. Expected an object, got ${paramType}`,
hint: 'See https://docs.astro.build/en/reference/api-reference/#getstaticpaths for more information on getStaticPaths.',
},
InvalidGetStaticPathsReturn: {
code: 3011,
message: (returnType) =>
`Invalid type returned by getStaticPaths. Expected an array, got ${returnType}`,
hint: 'See https://docs.astro.build/en/reference/api-reference/#getstaticpaths for more information on getStaticPaths.',
},
GetStaticPathsDeprecatedRSS: {
code: 3012,
message:
'The RSS helper has been removed from getStaticPaths! Try the new @astrojs/rss package instead.',
hint: 'See https://docs.astro.build/en/guides/rss/ for more information.',
},
GetStaticPathsExpectedParams: {
code: 3013,
message: 'Missing or empty required params property on getStaticPaths route',
hint: 'See https://docs.astro.build/en/reference/api-reference/#getstaticpaths for more information on getStaticPaths.',
},
GetStaticPathsInvalidRouteParam: {
code: 3014,
message: (key: string, value: any) =>
`Invalid getStaticPaths route parameter for \`${key}\`. Expected a string or number, received \`${typeof value}\` ("${value}")`,
hint: 'See https://docs.astro.build/en/reference/api-reference/#getstaticpaths for more information on getStaticPaths.',
},
GetStaticPathsRequired: {
code: 3015,
message:
'getStaticPaths() function is required for dynamic routes. Make sure that you `export` a `getStaticPaths` function from your dynamic route.',
hint: `See https://docs.astro.build/en/core-concepts/routing/#dynamic-routes for more information on dynamic routes.
Alternatively, set \`output: "server"\` in your Astro config file to switch to a non-static server build.
See https://docs.astro.build/en/guides/server-side-rendering/ for more information on non-static rendering.`,
},
ReservedSlotName: {
code: 3016,
message: (slotName: string) =>
`Unable to create a slot named "${slotName}". ${slotName}" is a reserved slot name! Please update the name of this slot.`,
},
NoAdapterInstalled: {
code: 3017,
message: `Cannot use \`output: 'server'\` without an adapter. Please install and configure the appropriate server adapter for your final deployment.`,
hint: 'See https://docs.astro.build/en/guides/server-side-rendering/ for more information.',
},
NoMatchingImport: {
code: 3018,
message: (componentName: string) =>
`Could not render ${componentName}. No matching import has been found for ${componentName}.`,
hint: 'Please make sure the component is properly imported.',
},
// CSS Errors - 4xxx
UnknownCSSError: {
code: 4000,
},
CSSSyntaxError: {
code: 4001,
},
// Vite Errors - 5xxx
UnknownViteError: {
code: 5000,
},
FailedToLoadModuleSSR: {
code: 5001,
message: (importName: string) => `Could not import "${importName}".`,
hint: 'This is often caused by a typo in the import path. Please make sure the file exists.',
},
InvalidGlob: {
code: 5002,
message: (globPattern: string) =>
`Invalid glob pattern: "${globPattern}". Glob patterns must start with './', '../' or '/'.`,
hint: 'See https://docs.astro.build/en/guides/imports/#glob-patterns for more information on supported glob patterns.',
},
// Markdown Errors - 6xxx
UnknownMarkdownError: {
code: 6000,
},
MarkdownFrontmatterParseError: {
code: 6001,
},
// Config Errors - 7xxx
UnknownConfigError: {
code: 7000,
},
ConfigNotFound: {
code: 7001,
message: (configFile: string) =>
`Unable to resolve --config "${configFile}"! Does the file exist?`,
},
ConfigLegacyKey: {
code: 7002,
message: (legacyConfigKey: string) => `Legacy configuration detected: "${legacyConfigKey}".`,
hint: 'Please update your configuration to the new format!\nSee https://astro.build/config for more information.',
},
// Generic catch-all
UnknownError: {
code: 99999,
},
} as const);
type ValueOf<T> = T[keyof T];
export type AstroErrorCodes = ValueOf<{
[T in keyof typeof AstroErrorData]: typeof AstroErrorData[T]['code'];
}>;

View file

@ -1,9 +1,10 @@
import type { DiagnosticCode } from '@astrojs/compiler/shared/diagnostics.js';
import { AstroErrorCodes } from './codes.js';
import { AstroErrorCodes } from './errors-data.js';
import { codeFrame } from './printer.js';
import { getErrorDataByCode } from './utils.js';
interface ErrorProperties {
errorCode: AstroErrorCodes | DiagnosticCode;
code: AstroErrorCodes | DiagnosticCode;
name?: string;
message?: string;
location?: ErrorLocation;
@ -19,31 +20,32 @@ export interface ErrorLocation {
}
type ErrorTypes =
| 'CSSError'
| 'AstroError'
| 'CompilerError'
| 'RuntimeError'
| 'CSSError'
| 'MarkdownError'
| 'AstroAggregateError';
| 'InternalError'
| 'AggregateError';
export class AstroError extends Error {
public errorCode: AstroErrorCodes | DiagnosticCode;
public code: AstroErrorCodes | DiagnosticCode;
public loc: ErrorLocation | undefined;
public hint: string | undefined;
public frame: string | undefined;
type: ErrorTypes | undefined;
type: ErrorTypes = 'AstroError';
constructor(props: ErrorProperties, ...params: any) {
super(...params);
const { errorCode, name, message, stack, location, hint, frame } = props;
const { code, name, message, stack, location, hint, frame } = props;
this.errorCode = errorCode;
this.code = code;
if (name) {
this.name = name;
} else {
// If we don't have a name, let's generate one from the code
this.name = AstroErrorCodes[errorCode];
this.name = getErrorDataByCode(this.code)?.name ?? 'UnknownError';
}
if (message) this.message = message;
// Only set this if we actually have a stack passed, otherwise uses Error's
@ -53,9 +55,10 @@ export class AstroError extends Error {
this.frame = frame;
}
public setErrorCode(errorCode: AstroErrorCodes | DiagnosticCode) {
this.errorCode = errorCode;
this.name = AstroErrorCodes[errorCode];
public setErrorCode(errorCode: AstroErrorCodes) {
this.code = errorCode;
this.name = getErrorDataByCode(this.code)?.name ?? 'UnknownError';
}
public setLocation(location: ErrorLocation): void {
@ -77,51 +80,52 @@ export class AstroError extends Error {
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';
static is(err: Error | unknown): err is AstroError {
return (err as AstroError).type === 'AstroError';
}
}
export class CompilerError extends AstroError {
type: ErrorTypes = 'CompilerError';
constructor(
props: ErrorProperties & { errorCode: DiagnosticCode | AstroErrorCodes.UnknownCompilerError },
...params: any
) {
constructor(props: Omit<ErrorProperties, 'code'> & { code: DiagnosticCode }, ...params: any) {
super(props, ...params);
this.name = 'CompilerError';
}
static is(err: Error | unknown): boolean {
static is(err: Error | unknown): err is CompilerError {
return (err as CompilerError).type === 'CompilerError';
}
}
export class RuntimeError extends AstroError {
type: ErrorTypes = 'RuntimeError';
export class CSSError extends AstroError {
type: ErrorTypes = 'CSSError';
static is(err: Error | unknown): boolean {
return (err as RuntimeError).type === 'RuntimeError';
static is(err: Error | unknown): err is CSSError {
return (err as CSSError).type === 'CSSError';
}
}
export class MarkdownError extends AstroError {
type: ErrorTypes = 'MarkdownError';
static is(err: Error | unknown): boolean {
static is(err: Error | unknown): err is MarkdownError {
return (err as MarkdownError).type === 'MarkdownError';
}
}
export class InternalError extends AstroError {
type: ErrorTypes = 'InternalError';
static is(err: Error | unknown): err is InternalError {
return (err as InternalError).type === 'InternalError';
}
}
export class AggregateError extends AstroError {
type: ErrorTypes = 'AstroAggregateError';
type: ErrorTypes = 'AggregateError';
errors: AstroError[];
// Despite being a collection of errors, AggregateError still needs to have a main error attached to it
@ -132,8 +136,8 @@ export class AggregateError extends AstroError {
this.errors = props.errors;
}
static is(err: Error | unknown): boolean {
return (err as AggregateError).type === 'AstroAggregateError';
static is(err: Error | unknown): err is AggregateError {
return (err as AggregateError).type === 'AggregateError';
}
}
@ -143,6 +147,7 @@ export class AggregateError extends AstroError {
*/
export interface ErrorWithMetadata {
[name: string]: any;
name: string;
type?: ErrorTypes;
message: string;
stack: string;
@ -157,4 +162,5 @@ export interface ErrorWithMetadata {
line?: number;
column?: number;
};
cause?: any;
}

View file

@ -1,12 +1,5 @@
export { AstroErrorCodes } from './codes.js';
export type { ErrorLocation, ErrorWithMetadata } from './errors';
export {
AggregateError,
AstroError,
CompilerError,
CSSError,
MarkdownError,
RuntimeError,
} from './errors.js';
export { AstroErrorData, type AstroErrorCodes } from './errors-data.js';
export { AggregateError, AstroError, CompilerError, CSSError, MarkdownError } from './errors.js';
export { codeFrame } from './printer.js';
export { collectInfoFromStacktrace, createSafeError, positionAt } from './utils.js';
export { createSafeError, positionAt } from './utils.js';

View file

@ -1,13 +1,12 @@
import eol from 'eol';
import type { ErrorLocation } from './errors.js';
import { normalizeLF } from './utils.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)
const lines = normalizeLF(src)
.split('\n')
.map((ln) => ln.replace(/\t/g, ' '));
// grab 2 lines before, and 3 lines after focused line

View file

@ -1,54 +1,5 @@
import eol from 'eol';
import stripAnsi from 'strip-ansi';
import type { SSRError } from '../../@types/astro.js';
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');
}
import { DiagnosticCode } from '@astrojs/compiler/shared/diagnostics.js';
import { AstroErrorCodes, AstroErrorData } from './errors-data.js';
/**
* Get the line and character based on the offset
@ -125,3 +76,18 @@ export function createSafeError(err: any): Error {
? err
: new Error(JSON.stringify(err));
}
export function normalizeLF(code: string) {
return code.replace(/\r\n|\r(?!\n)|\n/g, '\n');
}
export function getErrorDataByCode(code: AstroErrorCodes | DiagnosticCode) {
const entry = Object.entries(AstroErrorData).find((data) => data[1].code === code);
if (entry) {
return {
name: entry[0],
data: entry[1],
};
}
}

View file

@ -278,6 +278,17 @@ export function formatErrorMessage(err: ErrorWithMetadata, args: string[] = []):
args.push(dim(err.stack));
args.push(``);
}
if (err.cause) {
args.push(` ${bold('Cause:')}`);
if (err.cause instanceof Error) {
args.push(dim(err.cause.stack ?? err.cause.toString()));
} else {
args.push(JSON.stringify(err.cause));
}
args.push(``);
}
return args.join('\n');
}

View file

@ -8,6 +8,7 @@ import { attachToResponse } from '../cookies/index.js';
import { getParams } from '../routing/params.js';
import { createResult } from './result.js';
import { callGetStaticPaths, findPathItemByKey, RouteCache } from './route-cache.js';
import { AstroError, AstroErrorData } from '../errors/index.js';
interface GetParamsAndPropsOptions {
mod: ComponentInstance;
@ -45,7 +46,7 @@ export async function getParamsAndProps(
routeCacheEntry = await callGetStaticPaths({ mod, route, isValidate: true, logging, ssr });
routeCache.set(route, routeCacheEntry);
}
const matchedStaticPath = findPathItemByKey(routeCacheEntry.staticPaths, params);
const matchedStaticPath = findPathItemByKey(routeCacheEntry.staticPaths, params, route);
if (!matchedStaticPath && !ssr) {
return GetParamsAndPropsError.NoMatchingStaticPath;
}
@ -71,9 +72,13 @@ export async function renderPage(mod: ComponentInstance, ctx: RenderContext, env
});
if (paramsAndPropsRes === GetParamsAndPropsError.NoMatchingStaticPath) {
throw new Error(
`[getStaticPath] route pattern matched, but no matching static path found. (${ctx.pathname})`
);
throw new AstroError({
...AstroErrorData.NoMatchingStaticPathFound,
message: AstroErrorData.NoMatchingStaticPathFound.message(ctx.pathname),
hint: ctx.route?.component
? AstroErrorData.NoMatchingStaticPathFound.hint([ctx.route?.component])
: '',
});
}
const [params, pageProps] = paramsAndPropsRes;
@ -114,7 +119,14 @@ export async function renderPage(mod: ComponentInstance, ctx: RenderContext, env
});
}
const response = await runtimeRenderPage(result, Component, pageProps, null, env.streaming);
const response = await runtimeRenderPage(
result,
Component,
pageProps,
null,
env.streaming,
ctx.route
);
// If there is an Astro.cookies instance, attach it to the response so that
// adapters can grab the Set-Cookie headers.

View file

@ -13,16 +13,19 @@ import type {
import { renderSlot, stringifyChunk } from '../../runtime/server/index.js';
import { renderJSX } from '../../runtime/server/jsx.js';
import { AstroCookies } from '../cookies/index.js';
import { AstroError, AstroErrorData } from '../errors/index.js';
import { LogOptions, warn } from '../logger/core.js';
import { isScriptRequest } from './script.js';
import { isCSSRequest } from './util.js';
const clientAddressSymbol = Symbol.for('astro.clientAddress');
function onlyAvailableInSSR(name: string) {
function onlyAvailableInSSR(name: 'Astro.redirect') {
return function _onlyAvailableInSSR() {
// TODO add more guidance when we have docs and adapters.
throw new Error(`Oops, you are trying to use ${name}, which is only available with SSR.`);
switch (name) {
case 'Astro.redirect':
throw new AstroError(AstroErrorData.StaticRedirectNotAllowed);
}
};
}
@ -66,9 +69,10 @@ class Slots {
if (slots) {
for (const key of Object.keys(slots)) {
if ((this as any)[key] !== undefined) {
throw new Error(
`Unable to create a slot named "${key}". "${key}" is a reserved slot name!\nPlease update the name of this slot.`
);
throw new AstroError({
...AstroErrorData.ReservedSlotName,
message: AstroErrorData.ReservedSlotName.message(key),
});
}
Object.defineProperty(this, key, {
get() {
@ -172,13 +176,14 @@ export function createResult(args: CreateResultArgs): SSRResult {
get clientAddress() {
if (!(clientAddressSymbol in request)) {
if (args.adapterName) {
throw new Error(
`Astro.clientAddress is not available in the ${args.adapterName} adapter. File an issue with the adapter to add support.`
);
throw new AstroError({
...AstroErrorData.SSRClientAddressNotAvailableInAdapter,
message: AstroErrorData.SSRClientAddressNotAvailableInAdapter.message(
args.adapterName
),
});
} else {
throw new Error(
`Astro.clientAddress is not available in your environment. Ensure that you are using an SSR adapter that supports this feature.`
);
throw new AstroError(AstroErrorData.StaticClientAddressNotAvailable);
}
}

View file

@ -7,6 +7,7 @@ import type {
RouteData,
RuntimeMode,
} from '../../@types/astro';
import { AstroError, AstroErrorData } from '../errors/index.js';
import { debug, LogOptions, warn } from '../logger/core.js';
import { stringifyParams } from '../routing/params.js';
@ -28,39 +29,39 @@ export async function callGetStaticPaths({
route,
ssr,
}: CallGetStaticPathsOptions): Promise<RouteCacheEntry> {
validateDynamicRouteModule(mod, { ssr, logging });
validateDynamicRouteModule(mod, { ssr, logging, route });
// No static paths in SSR mode. Return an empty RouteCacheEntry.
if (ssr) {
return { staticPaths: Object.assign([], { keyed: new Map() }) };
}
// Add a check here to my TypeScript happy.
// Add a check here to make TypeScript happy.
// This is already checked in validateDynamicRouteModule().
if (!mod.getStaticPaths) {
throw new Error('Unexpected Error.');
}
// Calculate your static paths.
let staticPaths: GetStaticPathsResult = [];
staticPaths = (
await mod.getStaticPaths({
paginate: generatePaginateFunction(route),
rss() {
throw new Error(
'The RSS helper has been removed from getStaticPaths! Try the new @astrojs/rss package instead. See https://docs.astro.build/en/guides/rss/'
);
},
})
).flat();
staticPaths = await mod.getStaticPaths({
paginate: generatePaginateFunction(route),
rss() {
throw new AstroError(AstroErrorData.GetStaticPathsDeprecatedRSS);
},
});
if (isValidate) {
validateGetStaticPathsResult(staticPaths, logging, route);
}
staticPaths = staticPaths.flat();
const keyedStaticPaths = staticPaths as GetStaticPathsResultKeyed;
keyedStaticPaths.keyed = new Map<string, GetStaticPathsItem>();
for (const sp of keyedStaticPaths) {
const paramsKey = stringifyParams(sp.params);
const paramsKey = stringifyParams(sp.params, route.component);
keyedStaticPaths.keyed.set(paramsKey, sp);
}
if (isValidate) {
validateGetStaticPathsResult(keyedStaticPaths, logging);
}
return {
staticPaths: keyedStaticPaths,
};
@ -109,8 +110,12 @@ export class RouteCache {
}
}
export function findPathItemByKey(staticPaths: GetStaticPathsResultKeyed, params: Params) {
const paramsKey = stringifyParams(params);
export function findPathItemByKey(
staticPaths: GetStaticPathsResultKeyed,
params: Params,
route: RouteData
) {
const paramsKey = stringifyParams(params, route.component);
const matchedStaticPath = staticPaths.keyed.get(paramsKey);
if (matchedStaticPath) {
return matchedStaticPath;

View file

@ -114,11 +114,6 @@ function isSpread(str: string) {
function validateSegment(segment: string, file = '') {
if (!file) file = segment;
if (/^\$/.test(segment)) {
throw new Error(
`Invalid route ${file} \u2014 Astro's Collections API has been replaced by dynamic route params.`
);
}
if (/\]\[/.test(segment)) {
throw new Error(`Invalid route ${file} \u2014 parameters must be separated`);
}

View file

@ -27,10 +27,10 @@ export function getParams(array: string[]) {
* values and create a stringified key for the route
* that can be used to match request routes
*/
export function stringifyParams(params: Params) {
export function stringifyParams(params: Params, routeComponent: string) {
// validate parameter values then stringify each value
const validatedParams = Object.entries(params).reduce((acc, next) => {
validateGetStaticPathsParameter(next);
validateGetStaticPathsParameter(next, routeComponent);
const [key, value] = next;
acc[key] = typeof value === 'undefined' ? undefined : `${value}`;
return acc;

View file

@ -1,15 +1,20 @@
import type { ComponentInstance, GetStaticPathsResult } from '../../@types/astro';
import type { ComponentInstance, GetStaticPathsResult, RouteData } from '../../@types/astro';
import { AstroError, AstroErrorData } from '../errors/index.js';
import type { LogOptions } from '../logger/core';
import { warn } from '../logger/core.js';
const VALID_PARAM_TYPES = ['string', 'number', 'undefined'];
/** Throws error for invalid parameter in getStaticPaths() response */
export function validateGetStaticPathsParameter([key, value]: [string, any]) {
export function validateGetStaticPathsParameter([key, value]: [string, any], route: string) {
if (!VALID_PARAM_TYPES.includes(typeof value)) {
throw new Error(
`[getStaticPaths] invalid route parameter for "${key}". Expected a string or number, received \`${value}\` ("${typeof value}")`
);
throw new AstroError({
...AstroErrorData.GetStaticPathsInvalidRouteParam,
message: AstroErrorData.GetStaticPathsInvalidRouteParam.message(key, value),
location: {
file: route,
},
});
}
}
@ -19,57 +24,76 @@ export function validateDynamicRouteModule(
{
ssr,
logging,
route,
}: {
ssr: boolean;
logging: LogOptions;
route: RouteData;
}
) {
if ((mod as any).createCollection) {
throw new Error(`[createCollection] deprecated. Please use getStaticPaths() instead.`);
}
if (ssr && mod.getStaticPaths) {
warn(logging, 'getStaticPaths', 'getStaticPaths() is ignored when "output: server" is set.');
}
if (!ssr && !mod.getStaticPaths) {
throw new Error(
`[getStaticPaths] getStaticPaths() function is required.
Make sure that you \`export\` a \`getStaticPaths\` function from your dynamic route.
Alternatively, set \`output: "server"\` in your Astro config file to switch to a non-static server build. `
);
throw new AstroError({
...AstroErrorData.GetStaticPathsRequired,
location: { file: route.component },
});
}
}
/** Throw error for malformed getStaticPaths() response */
export function validateGetStaticPathsResult(result: GetStaticPathsResult, logging: LogOptions) {
/** Throw error and log warnings for malformed getStaticPaths() response */
export function validateGetStaticPathsResult(
result: GetStaticPathsResult,
logging: LogOptions,
route: RouteData
) {
if (!Array.isArray(result)) {
throw new Error(
`[getStaticPaths] invalid return value. Expected an array of path objects, but got \`${JSON.stringify(
result
)}\`.`
);
throw new AstroError({
...AstroErrorData.InvalidGetStaticPathsReturn,
message: AstroErrorData.InvalidGetStaticPathsReturn.message(typeof result),
location: {
file: route.component,
},
});
}
result.forEach((pathObject) => {
if (!pathObject.params) {
warn(
logging,
'getStaticPaths',
`invalid path object. Expected an object with key \`params\`, but got \`${JSON.stringify(
pathObject
)}\`. Skipped.`
);
return;
if (
pathObject.params === undefined ||
pathObject.params === null ||
(pathObject.params && Object.keys(pathObject.params).length === 0)
) {
throw new AstroError({
...AstroErrorData.GetStaticPathsExpectedParams,
location: {
file: route.component,
},
});
}
if (typeof pathObject.params !== 'object') {
throw new AstroError({
...AstroErrorData.InvalidStaticPathParam,
message: AstroErrorData.InvalidStaticPathParam.message(typeof pathObject.params),
location: {
file: route.component,
},
});
}
// TODO: Replace those with errors? They technically don't crash the build, but users might miss the warning. - erika, 2022-11-07
for (const [key, val] of Object.entries(pathObject.params)) {
if (!(typeof val === 'undefined' || typeof val === 'string')) {
if (!(typeof val === 'undefined' || typeof val === 'string' || typeof val === 'number')) {
warn(
logging,
'getStaticPaths',
`invalid path param: ${key}. A string value was expected, but got \`${JSON.stringify(
`invalid path param: ${key}. A string, number or undefined value was expected, but got \`${JSON.stringify(
val
)}\`.`
);
}
if (val === '') {
if (typeof val === 'string' && val === '') {
warn(
logging,
'getStaticPaths',

View file

@ -1,5 +1,5 @@
import { ZodError } from 'zod';
import { AstroErrorCodes, ErrorWithMetadata } from '../core/errors/index.js';
import { AstroErrorData, ErrorWithMetadata } from '../core/errors/index.js';
const EVENT_ERROR = 'ASTRO_CLI_ERROR';
@ -46,7 +46,7 @@ export function eventConfigError({
isFatal: boolean;
}): { eventName: string; payload: ConfigErrorEventPayload }[] {
const payload: ConfigErrorEventPayload = {
code: AstroErrorCodes.ConfigError,
code: AstroErrorData.UnknownConfigError.code,
isFatal,
isConfig: true,
cliCommand: cmd,
@ -65,7 +65,7 @@ export function eventError({
isFatal: boolean;
}): { eventName: string; payload: ErrorEventPayload }[] {
const payload: ErrorEventPayload = {
code: err.code || AstroErrorCodes.UnknownError,
code: err.code || AstroErrorData.UnknownError.code,
plugin: err.plugin,
cliCommand: cmd,
isFatal: isFatal,

View file

@ -1,5 +1,7 @@
import type { PluginObj } from '@babel/core';
import * as t from '@babel/types';
import { AstroErrorData } from '../core/errors/errors-data.js';
import { AstroError } from '../core/errors/errors.js';
import { resolvePath } from '../core/util.js';
import { HydrationDirectiveProps } from '../runtime/server/hydration.js';
import type { PluginMetadata } from '../vite-plugin-astro/types';
@ -310,11 +312,10 @@ export default function astroJSX(): PluginObj {
addClientMetadata(parentNode, meta);
}
} else {
throw new Error(
`Unable to match <${getTagName(
parentNode
)}> with client:* directive to an import statement!`
);
throw new AstroError({
...AstroErrorData.NoMatchingImport,
message: AstroErrorData.NoMatchingImport.message(getTagName(parentNode)),
});
}
},
},

View file

@ -4,6 +4,7 @@ import type {
SSRLoadedRenderer,
SSRResult,
} from '../../@types/astro';
import { AstroError, AstroErrorData } from '../../core/errors/index.js';
import { escapeHTML } from './escape.js';
import { serializeProps } from './serialize.js';
import { serializeListValue } from './util.js';
@ -27,7 +28,10 @@ interface ExtractedProps {
// Used to extract the directives, aka `client:load` information about a component.
// Finds these special props and removes them from what gets passed into the component.
export function extractDirectives(inputProps: Record<string | number, any>): ExtractedProps {
export function extractDirectives(
displayName: string,
inputProps: Record<string | number, any>
): ExtractedProps {
let extracted: ExtractedProps = {
isPage: false,
hydration: null,
@ -83,9 +87,10 @@ export function extractDirectives(inputProps: Record<string | number, any>): Ext
extracted.hydration.directive === 'media' &&
typeof extracted.hydration.value !== 'string'
) {
throw new Error(
'Error: Media query must be provided for "client:media", similar to client:media="(max-width: 600px)"'
);
throw new AstroError({
...AstroErrorData.MissingMediaQueryDirective,
message: AstroErrorData.MissingMediaQueryDirective.message(displayName),
});
}
break;

View file

@ -1,6 +1,12 @@
import type { AstroComponentMetadata, SSRLoadedRenderer, SSRResult } from '../../../@types/astro';
import type {
AstroComponentMetadata,
RouteData,
SSRLoadedRenderer,
SSRResult,
} from '../../../@types/astro';
import type { RenderInstruction } from './types.js';
import { AstroError, AstroErrorData } from '../../../core/errors/index.js';
import { HTMLBytes, markHTMLString } from '../escape.js';
import { extractDirectives, generateHydrateScript } from '../hydration.js';
import { serializeProps } from '../serialize.js';
@ -27,9 +33,15 @@ function guessRenderers(componentUrl?: string): string[] {
return ['@astrojs/vue'];
case 'jsx':
case 'tsx':
return ['@astrojs/react', '@astrojs/preact', '@astrojs/vue (jsx)'];
return ['@astrojs/react', '@astrojs/preact', '@astrojs/solid', '@astrojs/vue (jsx)'];
default:
return ['@astrojs/react', '@astrojs/preact', '@astrojs/vue', '@astrojs/svelte'];
return [
'@astrojs/react',
'@astrojs/preact',
'@astrojs/solid',
'@astrojs/vue',
'@astrojs/svelte',
];
}
}
@ -54,7 +66,8 @@ export async function renderComponent(
displayName: string,
Component: unknown,
_props: Record<string | number, any>,
slots: any = {}
slots: any = {},
route?: RouteData | undefined
): Promise<ComponentIterable> {
Component = (await Component) ?? Component;
@ -100,7 +113,7 @@ export async function renderComponent(
const { renderers } = result._metadata;
const metadata: AstroComponentMetadata = { displayName };
const { hydration, isPage, props } = extractDirectives(_props);
const { hydration, isPage, props } = extractDirectives(displayName, _props);
let html = '';
let attrs: Record<string, string> | undefined = undefined;
@ -110,21 +123,9 @@ export async function renderComponent(
metadata.componentExport = hydration.componentExport;
metadata.componentUrl = hydration.componentUrl;
}
const probableRendererNames = guessRenderers(metadata.componentUrl);
if (
Array.isArray(renderers) &&
renderers.length === 0 &&
typeof Component !== 'string' &&
!componentIsHTMLElement(Component)
) {
const message = `Unable to render ${metadata.displayName}!
There are no \`integrations\` set in your \`astro.config.mjs\` file.
Did you mean to add ${formatList(probableRendererNames.map((r) => '`' + r + '`'))}?`;
throw new Error(message);
}
const validRenderers = renderers.filter((r) => r.name !== 'astro:jsx');
const { children, slotInstructions } = await renderSlots(result, slots);
// Call the renderers `check` hook to see if any claim this component.
@ -182,8 +183,8 @@ Did you mean to add ${formatList(probableRendererNames.map((r) => '`' + r + '`')
);
}
// Attempt: user only has a single renderer, default to that
if (!renderer && renderers.length === 1) {
renderer = renderers[0];
if (!renderer && validRenderers.length === 1) {
renderer = validRenderers[0];
}
// Attempt: can we guess the renderer from the export extension?
if (!renderer) {
@ -197,26 +198,31 @@ Did you mean to add ${formatList(probableRendererNames.map((r) => '`' + r + '`')
// If no one claimed the renderer
if (!renderer) {
if (metadata.hydrate === 'only') {
// TODO: improve error message
throw new Error(`Unable to render ${metadata.displayName}!
Using the \`client:only\` hydration strategy, Astro needs a hint to use the correct renderer.
Did you mean to pass <${metadata.displayName} client:only="${probableRendererNames
.map((r) => r.replace('@astrojs/', ''))
.join('|')}" />
`);
throw new AstroError({
...AstroErrorData.NoClientOnlyHint,
message: AstroErrorData.NoClientOnlyHint.message(metadata.displayName),
hint: AstroErrorData.NoClientOnlyHint.hint(
probableRendererNames.map((r) => r.replace('@astrojs/', '')).join('|')
),
});
} else if (typeof Component !== 'string') {
const matchingRenderers = renderers.filter((r) => probableRendererNames.includes(r.name));
const plural = renderers.length > 1;
const matchingRenderers = validRenderers.filter((r) =>
probableRendererNames.includes(r.name)
);
const plural = validRenderers.length > 1;
if (matchingRenderers.length === 0) {
throw new Error(`Unable to render ${metadata.displayName}!
There ${plural ? 'are' : 'is'} ${renderers.length} renderer${
plural ? 's' : ''
} configured in your \`astro.config.mjs\` file,
but ${plural ? 'none were' : 'it was not'} able to server-side render ${metadata.displayName}.
Did you mean to enable ${formatList(probableRendererNames.map((r) => '`' + r + '`'))}?`);
throw new AstroError({
...AstroErrorData.NoMatchingRenderer,
message: AstroErrorData.NoMatchingRenderer.message(
metadata.displayName,
metadata?.componentUrl?.split('.').pop(),
plural,
validRenderers.length
),
hint: AstroErrorData.NoMatchingRenderer.hint(
formatList(probableRendererNames.map((r) => '`' + r + '`'))
),
});
} else if (matchingRenderers.length === 1) {
// We already know that renderer.ssr.check() has failed
// but this will throw a much more descriptive error!
@ -264,9 +270,14 @@ If you're still stuck, please open an issue on GitHub or join us at https://astr
renderer.name !== '@astrojs/lit' &&
metadata.hydrate
) {
throw new Error(
`${metadata.displayName} component has a \`client:${metadata.hydrate}\` directive, but no client entrypoint was provided by ${renderer.name}!`
);
throw new AstroError({
...AstroErrorData.NoClientEntrypoint,
message: AstroErrorData.NoClientEntrypoint.message(
displayName,
metadata.hydrate,
renderer.name
),
});
}
// This is a custom element without a renderer. Because of that, render it

View file

@ -1,7 +1,8 @@
import type { SSRResult } from '../../../@types/astro';
import type { RouteData, SSRResult } from '../../../@types/astro';
import type { ComponentIterable } from './component';
import type { AstroComponentFactory } from './index';
import { AstroError, AstroErrorData } from '../../../core/errors/index.js';
import { isHTMLString } from '../escape.js';
import { createResponse } from '../response.js';
import { isAstroComponent, isAstroComponentFactory, renderAstroComponent } from './astro.js';
@ -49,17 +50,32 @@ export async function renderPage(
componentFactory: AstroComponentFactory | NonAstroPageComponent,
props: any,
children: any,
streaming: boolean
streaming: boolean,
route?: RouteData | undefined
): Promise<Response> {
if (!isAstroComponentFactory(componentFactory)) {
const pageProps: Record<string, any> = { ...(props ?? {}), 'server:root': true };
const output = await renderComponent(
result,
componentFactory.name,
componentFactory,
pageProps,
null
);
let output: ComponentIterable;
try {
output = await renderComponent(
result,
componentFactory.name,
componentFactory,
pageProps,
null,
route
);
} catch (e) {
if (AstroError.is(e) && !e.loc) {
e.setLocation({
file: route?.component,
});
}
throw e;
}
// Accumulate the HTML string and append the head if necessary.
const bytes = await iterableToHTMLBytes(result, output, async (parts) => {
@ -106,6 +122,14 @@ export async function renderPage(
}
controller.close();
} catch (e) {
// We don't have a lot of information downstream, and upstream we can't catch the error properly
// So let's add the location here
if (AstroError.is(e) && !e.loc) {
e.setLocation({
file: route?.component,
});
}
controller.error(e);
}
}
@ -123,7 +147,16 @@ export async function renderPage(
// We double check if the file return a Response
if (!(factoryReturnValue instanceof Response)) {
throw new Error('Only instance of Response can be returned from an Astro file');
throw new AstroError({
...AstroErrorData.OnlyResponseCanBeReturned,
message: AstroErrorData.OnlyResponseCanBeReturned.message(
route?.route,
typeof factoryReturnValue
),
location: {
file: route?.component,
},
});
}
return factoryReturnValue;

View file

@ -57,13 +57,27 @@ export default function createVitePluginAstroServer({
});
};
},
// HACK: hide `.tip` in Vite's ErrorOverlay and replace [vite] messages with [astro]
// HACK: Manually replace code in Vite's overlay to fit it to our needs
// In the future, we'll instead take over the overlay entirely, which should be safer and cleaner
transform(code, id, opts = {}) {
if (opts.ssr) return;
if (!id.includes('vite/dist/client/client.mjs')) return;
return code
.replace(/\.tip \{[^}]*\}/gm, '.tip {\n display: none;\n}')
.replace(/\[vite\]/g, '[astro]');
return (
code
// Transform links in the message to clickable links
.replace(
"this.text('.message-body', message.trim());",
`const urlPattern = /(\\b(https?|ftp):\\/\\/[-A-Z0-9+&@#\\/%?=~_|!:,.;]*[-A-Z0-9+&@#\\/%=~_|])/gim;
function escapeHtml(unsafe){return unsafe.replace(/</g, "&lt;").replace(/>/g, "&gt;");}
const escapedMessage = escapeHtml(message);
this.root.querySelector(".message-body").innerHTML = escapedMessage.trim().replace(urlPattern, '<a href="$1" target="_blank">$1</a>');`
)
.replace('</style>', '.message-body a {\n color: #ededed;\n}\n</style>')
// Hide `.tip` in Vite's ErrorOverlay
.replace(/\.tip \{[^}]*\}/gm, '.tip {\n display: none;\n}')
// Replace [vite] messages with [astro]
.replace(/\[vite\]/g, '[astro]')
);
},
};
}

View file

@ -67,7 +67,7 @@ export async function handleRequest(
const err = createSafeError(_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);
const errorWithMetadata = collectErrorMetadata(err, config.root);
error(env.logging, null, msg.formatErrorMessage(errorWithMetadata));
handle500Response(moduleLoader, res, errorWithMetadata);

View file

@ -15,6 +15,7 @@ import { matchAllRoutes } from '../core/routing/index.js';
import { resolvePages } from '../core/util.js';
import { log404 } from './common.js';
import { handle404Response, writeSSRResult, writeWebResponse } from './response.js';
import { AstroErrorData } from '../core/errors/index.js';
type AsyncReturnType<T extends (...args: any) => Promise<any>> = T extends (
...args: any
@ -63,10 +64,14 @@ export async function matchRoute(
}
if (matches.length) {
const possibleRoutes = matches.flatMap((route) => route.component);
warn(
logging,
'getStaticPaths',
`Route pattern matched, but no matching static path found. (${pathname})`
`${AstroErrorData.NoMatchingStaticPathFound.message(
pathname
)}\n\n${AstroErrorData.NoMatchingStaticPathFound.hint(possibleRoutes)}`
);
}

View file

@ -8,7 +8,7 @@ import type { Plugin, ResolvedConfig } from 'vite';
import type { AstroSettings } from '../@types/astro';
import { pagesVirtualModuleId } from '../core/app/index.js';
import { cachedCompilation, CompileProps } from '../core/compile/index.js';
import { AstroErrorCodes, MarkdownError } from '../core/errors/index.js';
import { AstroErrorData, 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';
@ -27,7 +27,7 @@ function safeMatter(source: string, id: string) {
return matter(source);
} catch (err: any) {
const markdownError = new MarkdownError({
errorCode: AstroErrorCodes.GenericMarkdownError,
code: AstroErrorData.UnknownMarkdownError.code,
message: err.message,
stack: err.stack,
location: {
@ -36,7 +36,7 @@ function safeMatter(source: string, id: string) {
});
if (err.name === 'YAMLException') {
markdownError.setErrorCode(AstroErrorCodes.MarkdownFrontmatterParseError);
markdownError.setErrorCode(AstroErrorData.MarkdownFrontmatterParseError.code);
markdownError.setLocation({
file: id,
line: err.mark.line,

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 { AstroErrorCodes, MarkdownError } from '../core/errors/index.js';
import { AstroErrorData, 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';
@ -22,7 +22,7 @@ function safeMatter(source: string, id: string) {
return matter(source);
} catch (err: any) {
const markdownError = new MarkdownError({
errorCode: AstroErrorCodes.GenericMarkdownError,
code: AstroErrorData.UnknownMarkdownError.code,
message: err.message,
stack: err.stack,
location: {
@ -31,7 +31,7 @@ function safeMatter(source: string, id: string) {
});
if (err.name === 'YAMLException') {
markdownError.setErrorCode(AstroErrorCodes.MarkdownFrontmatterParseError);
markdownError.setErrorCode(AstroErrorData.MarkdownFrontmatterParseError.code);
markdownError.setLocation({
file: id,
line: err.mark.line,

View file

@ -1,5 +1,5 @@
import { expect } from 'chai';
import { AstroErrorCodes } from '../dist/core/errors/codes.js';
import { AstroErrorData } from '../dist/core/errors/errors-data.js';
import * as events from '../dist/events/index.js';
describe('Events', () => {
@ -426,7 +426,7 @@ describe('Events', () => {
expect(event).to.deep.equal({
eventName: 'ASTRO_CLI_ERROR',
payload: {
code: AstroErrorCodes.ConfigError,
code: AstroErrorData.UnknownConfigError.code,
isFatal: true,
isConfig: true,
cliCommand: 'COMMAND_NAME',
@ -467,7 +467,7 @@ describe('Events', () => {
expect(event).to.deep.equal({
eventName: 'ASTRO_CLI_ERROR',
payload: {
code: AstroErrorCodes.UnknownError,
code: AstroErrorData.UnknownError.code,
plugin: undefined,
isFatal: false,
cliCommand: 'COMMAND_NAME',

View file

@ -492,7 +492,6 @@ importers:
debug: 4.3.4
deepmerge-ts: 4.2.2
diff: 5.1.0
eol: 0.9.1
es-module-lexer: 0.10.5
esbuild: 0.14.54
execa: 6.1.0
@ -557,6 +556,7 @@ importers:
astro-scripts: link:../../scripts
chai: 4.3.7
cheerio: 1.0.0-rc.12
eol: 0.9.1
memfs: 3.4.10
mocha: 9.2.2
node-fetch: 3.2.10
@ -11635,7 +11635,7 @@ packages:
/eol/0.9.1:
resolution: {integrity: sha512-Ds/TEoZjwggRoz/Q2O7SE3i4Jm66mqTDfmdHdq/7DKVk3bro9Q8h6WdXKdPqFLMoqxrDK5SVRzHVPOS6uuGtrg==}
dev: false
dev: true
/error-ex/1.3.2:
resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==}