Fix runtime, improve code frame
This commit is contained in:
parent
95b3f254d8
commit
3df506a219
9 changed files with 191 additions and 105 deletions
|
@ -56,6 +56,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",
|
||||
|
@ -71,6 +72,7 @@
|
|||
"srcset-parse": "^1.1.0",
|
||||
"source-map": "^0.7.3",
|
||||
"string-width": "^5.0.0",
|
||||
"strip-ansi": "^7.0.1",
|
||||
"supports-esm": "^1.0.0",
|
||||
"vite": "^2.5.7",
|
||||
"yargs-parser": "^20.2.9",
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
|
26
packages/astro/src/runtime/astro.ts
Normal file
26
packages/astro/src/runtime/astro.ts
Normal 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>');
|
||||
}
|
|
@ -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 */
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
17
yarn.lock
17
yarn.lock
|
@ -2560,6 +2560,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"
|
||||
|
@ -5498,6 +5503,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"
|
||||
|
@ -10494,6 +10504,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"
|
||||
|
|
Loading…
Reference in a new issue