Fix runtime, improve code frame

This commit is contained in:
Drew Powers 2021-09-15 17:07:01 -06:00
parent 7296e0b0a2
commit 6e5ede2175
9 changed files with 191 additions and 105 deletions

View file

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

View file

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

View file

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

View file

@ -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
<body>
<main class="wrapper">
<h1>${statusCode ? `<span class="statusCode">${statusCode}</span> ` : ''}${title}</h1>
<p>${message.replace(/\n/g, '<br>')}</p>
<pre><code>${encode(message)}</code></pre>
</main>
</body>
</html>

View file

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

View file

@ -0,0 +1,26 @@
import type { AstroComponent, AstroComponentFactory } from '../internal';
export async function renderAstroComponent(component: InstanceType<typeof AstroComponent>) {
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) => `<style>${style}</style>`);
const scripts = Array.from(result.scripts);
return template.replace('</head>', styles.join('\n') + scripts.join('\n') + '</head>');
}

View file

@ -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<typeof AstroComponent>) {
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) => `<style>${style}</style>`);
const scripts = Array.from(result.scripts);
return template.replace('</head>', styles.join('\n') + scripts.join('\n') + '</head>');
}
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<string> {
// 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 */

View file

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

View file

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