diff --git a/packages/astro/package.json b/packages/astro/package.json index 76ab7ba0e..2d5541722 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -73,6 +73,7 @@ "fast-xml-parser": "^3.19.0", "fdir": "^5.1.0", "get-port": "^5.1.1", + "html-entities": "^2.3.2", "kleur": "^4.1.4", "mime": "^2.5.2", "morphdom": "^2.6.1", @@ -88,6 +89,7 @@ "source-map": "^0.7.3", "srcset-parse": "^1.1.0", "string-width": "^5.0.0", + "strip-ansi": "^7.0.1", "supports-esm": "^1.0.0", "vite": "^2.5.7", "yargs-parser": "^20.2.9", diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts index 762e3eca0..668efc18e 100644 --- a/packages/astro/src/@types/astro.ts +++ b/packages/astro/src/@types/astro.ts @@ -312,6 +312,8 @@ export type RSSResult = { url: string; xml?: string }; export type ScriptInfo = ScriptInfoInline | ScriptInfoExternal; +export type SSRError = Error & vite.ErrorPayload['err']; + export interface ScriptInfoInline { content: string; } diff --git a/packages/astro/src/dev/index.ts b/packages/astro/src/dev/index.ts index 1bec87310..8756f0b08 100644 --- a/packages/astro/src/dev/index.ts +++ b/packages/astro/src/dev/index.ts @@ -1,6 +1,6 @@ import type { NextFunction } from 'connect'; import type http from 'http'; -import type { AstroConfig, ManifestData, RouteCache, RouteData } from '../@types/astro'; +import type { AstroConfig, ManifestData, RouteCache, RouteData, SSRError } from '../@types/astro'; import type { LogOptions } from '../logger'; import type { HmrContext, ModuleNode } from 'vite'; @@ -11,8 +11,7 @@ import getEtag from 'etag'; import { performance } from 'perf_hooks'; import { fileURLToPath } from 'url'; import { createRequire } from 'module'; -import path from 'path'; -import { promises as fs } from 'fs'; +import stripAnsi from 'strip-ansi'; import vite from 'vite'; import { defaultLogOptions, error, info } from '../logger.js'; import { createRouteManifest, matchRoute } from '../runtime/routing.js'; @@ -153,6 +152,8 @@ export class AstroDevServer { return this.viteServer.middlewares.handle(req, res, next); } + let filePath: URL | undefined; + try { const route = matchRoute(pathname, this.manifest); @@ -165,9 +166,10 @@ export class AstroDevServer { this.mostRecentRoute = route; // handle .astro and .md pages + filePath = new URL(`./${route.component}`, this.config.projectRoot); const html = await ssr({ astroConfig: this.config, - filePath: new URL(`./${route.component}`, this.config.projectRoot), + filePath, logging: this.logging, mode: 'development', origin: this.origin, @@ -183,12 +185,16 @@ export class AstroDevServer { }); res.write(html); res.end(); - } catch (e) { - const err = e as Error; + } catch (err: any) { this.viteServer.ssrFixStacktrace(err); - console.log(err.stack); + this.viteServer.ws.send({ type: 'error', err }); const statusCode = 500; - const html = errorTemplate({ statusCode, title: 'Internal Error', tabTitle: '500: Error', message: err.message }); + const html = errorTemplate({ + statusCode, + title: 'Internal Error', + tabTitle: '500: Error', + message: stripAnsi(err.message), + }); info(this.logging, 'astro', msg.req({ url: pathname, statusCode: 500, reqTime: performance.now() - reqStart })); res.writeHead(statusCode, { 'Content-Type': mime.getType('.html') as string, diff --git a/packages/astro/src/dev/template/error.ts b/packages/astro/src/dev/template/error.ts index e29694302..7060e93a0 100644 --- a/packages/astro/src/dev/template/error.ts +++ b/packages/astro/src/dev/template/error.ts @@ -1,3 +1,5 @@ +import { encode } from 'html-entities'; + interface ErrorTemplateOptions { statusCode?: number; tabTitle: string; @@ -29,7 +31,7 @@ export function errorTemplate({ title, message, statusCode, tabTitle }: ErrorTem margin-top: 1rem; margin-bottom: 0; } - p { + pre { color: #999; font-size: 1.4em; margin-top: 0; @@ -43,7 +45,7 @@ export function errorTemplate({ title, message, statusCode, tabTitle }: ErrorTem

${statusCode ? `${statusCode} ` : ''}${title}

-

${message.replace(/\n/g, '
')}

+
${encode(message)}
diff --git a/packages/astro/src/internal/index.ts b/packages/astro/src/internal/index.ts index 0252294c6..aa5b9b81d 100644 --- a/packages/astro/src/internal/index.ts +++ b/packages/astro/src/internal/index.ts @@ -1,9 +1,10 @@ import type { AstroComponentMetadata } from '../@types/astro'; -import { renderAstroComponent } from '../runtime/ssr.js'; import { valueToEstree, Value } from 'estree-util-value-to-estree'; import * as astring from 'astring'; import shorthash from 'shorthash'; +import { renderAstroComponent } from '../runtime/astro.js'; + const { generate, GENERATOR } = astring; // A more robust version alternative to `JSON.stringify` that can handle most values // see https://github.com/remcohaszing/estree-util-value-to-estree#readme diff --git a/packages/astro/src/runtime/astro.ts b/packages/astro/src/runtime/astro.ts new file mode 100644 index 000000000..a525a0d3f --- /dev/null +++ b/packages/astro/src/runtime/astro.ts @@ -0,0 +1,26 @@ +import type { AstroComponent, AstroComponentFactory } from '../internal'; + +export async function renderAstroComponent(component: InstanceType) { + let template = ''; + + for await (const value of component) { + if (value || value === 0) { + template += value; + } + } + + return template; +} + +export async function renderToString(result: any, componentFactory: AstroComponentFactory, props: any, children: any) { + const Component = await componentFactory(result, props, children); + let template = await renderAstroComponent(Component); + return template; +} + +export async function renderPage(result: any, Component: AstroComponentFactory, props: any, children: any) { + const template = await renderToString(result, Component, props, children); + const styles = Array.from(result.styles).map((style) => ``); + const scripts = Array.from(result.scripts); + return template.replace('', styles.join('\n') + scripts.join('\n') + ''); +} diff --git a/packages/astro/src/runtime/ssr.ts b/packages/astro/src/runtime/ssr.ts index 522221d62..6c0596d8c 100644 --- a/packages/astro/src/runtime/ssr.ts +++ b/packages/astro/src/runtime/ssr.ts @@ -1,15 +1,17 @@ -import cheerio from 'cheerio'; -import * as eslexer from 'es-module-lexer'; +import type { BuildResult } from 'esbuild'; import type { ViteDevServer } from 'vite'; -import type { ComponentInstance, GetStaticPathsResult, Params, Props, RouteCache, RouteData, RuntimeMode, AstroConfig } from '../@types/astro'; +import type { AstroConfig, ComponentInstance, GetStaticPathsResult, Params, Props, RouteCache, RouteData, RuntimeMode, SSRError } from '../@types/astro'; import type { LogOptions } from '../logger'; +import cheerio from 'cheerio'; +import * as eslexer from 'es-module-lexer'; import { fileURLToPath } from 'url'; +import fs from 'fs'; import path from 'path'; +import { renderPage } from './astro.js'; import { generatePaginateFunction } from './paginate.js'; import { getParams, validateGetStaticPathsModule, validateGetStaticPathsResult } from './routing.js'; -import { parseNpmName, canonicalURL as getCanonicalURL } from './util.js'; -import type { AstroComponent, AstroComponentFactory } from '../internal'; +import { parseNpmName, canonicalURL as getCanonicalURL, codeFrame } from './util.js'; interface SSROptions { /** an instance of the AstroConfig */ @@ -36,31 +38,6 @@ interface SSROptions { // this prevents client-side errors such as the "double React bug" (https://reactjs.org/warnings/invalid-hook-call-warning.html#mismatching-versions-of-react-and-react-dom) let browserHash: string | undefined; -export async function renderAstroComponent(component: InstanceType) { - let template = ''; - - for await (const value of component) { - if (value || value === 0) { - template += value; - } - } - - return template; -} - -export async function renderToString(result: any, componentFactory: AstroComponentFactory, props: any, children: any) { - const Component = await componentFactory(result, props, children); - let template = await renderAstroComponent(Component); - return template; -} - -async function renderPage(result: any, Component: AstroComponentFactory, props: any, children: any) { - const template = await renderToString(result, Component, props, children); - const styles = Array.from(result.styles).map((style) => ``); - const scripts = Array.from(result.scripts); - return template.replace('', styles.join('\n') + scripts.join('\n') + ''); -} - const cache = new Map(); // TODO: improve validation and error handling here. @@ -155,79 +132,101 @@ async function resolveImportedModules(viteServer: ViteDevServer, file: string) { /** use Vite to SSR */ export async function ssr({ astroConfig, filePath, logging, mode, origin, pathname, route, routeCache, viteServer }: SSROptions): Promise { - // 1. load module - const mod = (await viteServer.ssrLoadModule(fileURLToPath(filePath))) as ComponentInstance; + try { + // 1. load module + const mod = (await viteServer.ssrLoadModule(fileURLToPath(filePath))) as ComponentInstance; - // 1.5. resolve renderers and imported modules. - // important that this happens _after_ ssrLoadModule, otherwise `importedModules` would be empty - const [renderers, importedModules] = await Promise.all([resolveRenderers(viteServer, astroConfig.renderers), resolveImportedModules(viteServer, fileURLToPath(filePath))]); + // 1.5. resolve renderers and imported modules. + // important that this happens _after_ ssrLoadModule, otherwise `importedModules` would be empty + const [renderers, importedModules] = await Promise.all([resolveRenderers(viteServer, astroConfig.renderers), resolveImportedModules(viteServer, fileURLToPath(filePath))]); - // 2. handle dynamic routes - let params: Params = {}; - let pageProps: Props = {}; - if (route && !route.pathname) { - if (route.params.length) { - const paramsMatch = route.pattern.exec(pathname)!; - params = getParams(route.params)(paramsMatch); + // 2. handle dynamic routes + let params: Params = {}; + let pageProps: Props = {}; + if (route && !route.pathname) { + if (route.params.length) { + const paramsMatch = route.pattern.exec(pathname)!; + params = getParams(route.params)(paramsMatch); + } + validateGetStaticPathsModule(mod); + routeCache[route.component] = + routeCache[route.component] || + ( + await mod.getStaticPaths!({ + paginate: generatePaginateFunction(route), + rss: () => { + /* noop */ + }, + }) + ).flat(); + validateGetStaticPathsResult(routeCache[route.component], logging); + const routePathParams: GetStaticPathsResult = routeCache[route.component]; + const matchedStaticPath = routePathParams.find(({ params: _params }) => JSON.stringify(_params) === JSON.stringify(params)); + if (!matchedStaticPath) { + throw new Error(`[getStaticPaths] route pattern matched, but no matching static path found. (${pathname})`); + } + pageProps = { ...matchedStaticPath.props } || {}; } - validateGetStaticPathsModule(mod); - routeCache[route.component] = - routeCache[route.component] || - ( - await mod.getStaticPaths!({ - paginate: generatePaginateFunction(route), - rss: () => { - /* noop */ - }, - }) - ).flat(); - validateGetStaticPathsResult(routeCache[route.component], logging); - const routePathParams: GetStaticPathsResult = routeCache[route.component]; - const matchedStaticPath = routePathParams.find(({ params: _params }) => JSON.stringify(_params) === JSON.stringify(params)); - if (!matchedStaticPath) { - throw new Error(`[getStaticPaths] route pattern matched, but no matching static path found. (${pathname})`); - } - pageProps = { ...matchedStaticPath.props } || {}; - } - // 3. render page - if (!browserHash && (viteServer as any)._optimizeDepsMetadata?.browserHash) browserHash = (viteServer as any)._optimizeDepsMetadata.browserHash; // note: this is "private" and may change over time - const fullURL = new URL(pathname, origin); + // 3. render page + if (!browserHash && (viteServer as any)._optimizeDepsMetadata?.browserHash) browserHash = (viteServer as any)._optimizeDepsMetadata.browserHash; // note: this is "private" and may change over time + const fullURL = new URL(pathname, origin); - const Component = await mod.default; - if (!Component) throw new Error(`Expected an exported Astro component but recieved typeof ${typeof Component}`); - if (!Component.isAstroComponentFactory) throw new Error(`Unable to SSR non-Astro component (${route?.component})`); + const Component = await mod.default; + if (!Component) throw new Error(`Expected an exported Astro component but recieved typeof ${typeof Component}`); + if (!Component.isAstroComponentFactory) throw new Error(`Unable to SSR non-Astro component (${route?.component})`); - let html = await renderPage( - { - styles: new Set(), - scripts: new Set(), - /** This function returns the `Astro` faux-global */ - createAstro(props: any) { - const site = new URL(origin); - const url = new URL('.' + pathname, site); - const canonicalURL = getCanonicalURL(pathname, astroConfig.buildOptions.site || origin); - return { isPage: true, site, request: { url, canonicalURL }, props }; + let html = await renderPage( + { + styles: new Set(), + scripts: new Set(), + /** This function returns the `Astro` faux-global */ + createAstro(props: any) { + const site = new URL(origin); + const url = new URL('.' + pathname, site); + const canonicalURL = getCanonicalURL(pathname, astroConfig.buildOptions.site || origin); + return { isPage: true, site, request: { url, canonicalURL }, props }; + }, + _metadata: { importedModules, renderers }, }, - _metadata: { importedModules, renderers }, - }, - Component, - {}, - null - ); + Component, + {}, + null + ); - // 4. modify response - if (mode === 'development') { - // inject Astro HMR code - html = injectAstroHMR(html); - // inject Vite HMR code - html = injectViteClient(html); - // replace client hydration scripts - html = resolveNpmImports(html); + // 4. modify response + if (mode === 'development') { + // inject Astro HMR code + html = injectAstroHMR(html); + // inject Vite HMR code + html = injectViteClient(html); + // replace client hydration scripts + html = resolveNpmImports(html); + } + + // 5. finish + return html; + } catch (e: any) { + // Astro error (thrown by esbuild so it needs to be formatted for Vite) + if (e.errors) { + const { location, pluginName, text } = (e as BuildResult).errors[0]; + const err = new Error(text) as SSRError; + if (location) err.loc = { file: location.file, line: location.line, column: location.column }; + const frame = codeFrame(await fs.promises.readFile(filePath, 'utf8'), err.loc); + err.frame = frame; + err.id = location?.file; + err.message = `${location?.file}: ${text} + +${frame} +`; + err.stack = e.stack; + if (pluginName) err.plugin = pluginName; + throw err; + } + + // Vite error (already formatted) + throw e; } - - // 5. finish - return html; } /** Injects Vite client code */ diff --git a/packages/astro/src/runtime/util.ts b/packages/astro/src/runtime/util.ts index 71cc60b71..8adbaa927 100644 --- a/packages/astro/src/runtime/util.ts +++ b/packages/astro/src/runtime/util.ts @@ -1,3 +1,4 @@ +import type { ErrorPayload } from 'vite'; import fs from 'fs'; import path from 'path'; @@ -44,3 +45,33 @@ export function parseNpmName(spec: string): { scope?: string; name: string; subp subpath, }; } + +/** generate code frame from esbuild error */ +export function codeFrame(src: string, loc: ErrorPayload['err']['loc']): string { + if (!loc) return ''; + + const lines = src.replace(/\r\n/g, '\n').split('\n'); + + // 1. 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); + } + + // 2. figure out gutter width + let gutterWidth = 0; + for (const lineNo of visibleLines) { + let w = `> ${lineNo}`; + if (w.length > gutterWidth) gutterWidth = w.length; + } + + // 3. 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 += `${[...new Array(gutterWidth)].join(' ')} | ${[...new Array(loc.column)].join(' ')}^\n`; + } + return output; +} diff --git a/yarn.lock b/yarn.lock index af33092b9..11102832d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2708,6 +2708,11 @@ ansi-regex@^6.0.0: resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-6.0.0.tgz#ecc7f5933cbe5ac7b33e209a5ff409ab1669c6b2" integrity sha512-tAaOSrWCHF+1Ear1Z4wnJCXA9GGox4K6Ic85a5qalES2aeEwQGr7UC93mwef49536PkCYjzkp0zIxfFvexJ6zQ== +ansi-regex@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-6.0.1.tgz#3183e38fae9a65d7cb5e53945cd5897d0260a06a" + integrity sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA== + ansi-styles@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-1.1.0.tgz#eaecbf66cd706882760b2f4691582b8f55d7a7de" @@ -5692,6 +5697,11 @@ html-encoding-sniffer@^2.0.1: dependencies: whatwg-encoding "^1.0.5" +html-entities@^2.3.2: + version "2.3.2" + resolved "https://registry.yarnpkg.com/html-entities/-/html-entities-2.3.2.tgz#760b404685cb1d794e4f4b744332e3b00dcfe488" + integrity sha512-c3Ab/url5ksaT0WyleslpBEthOzWhrjQbg75y7XUsfSzi3Dgzt0l8w5e7DylRn15MTlMMD58dTfzddNS2kcAjQ== + html-escaper@^2.0.0: version "2.0.2" resolved "https://registry.yarnpkg.com/html-escaper/-/html-escaper-2.0.2.tgz#dfd60027da36a36dfcbe236262c00a5822681453" @@ -10720,6 +10730,13 @@ strip-ansi@^7.0.0: dependencies: ansi-regex "^6.0.0" +strip-ansi@^7.0.1: + version "7.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.0.1.tgz#61740a08ce36b61e50e65653f07060d000975fb2" + integrity sha512-cXNxvT8dFNRVfhVME3JAe98mkXDYN2O1l7jmcwMnOslDeESg1rF/OZMtK0nRAhiari1unG5cD4jG3rapUAkLbw== + dependencies: + ansi-regex "^6.0.1" + strip-bom@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-3.0.0.tgz#2334c18e9c759f7bdd56fdef7e9ae3d588e68ed3"