Refactor rendering for testing (#5071)

* Refactor rendering for testing

* Correctly pass the mod for endpoints
This commit is contained in:
Matthew Phillips 2022-10-13 15:18:28 -04:00 committed by GitHub
parent 4866ff882a
commit df453e420f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 498 additions and 301 deletions

View file

@ -14,7 +14,7 @@ import { call as callEndpoint } from '../endpoint/index.js';
import { consoleLogDestination } from '../logger/console.js'; import { consoleLogDestination } from '../logger/console.js';
import { error } from '../logger/core.js'; import { error } from '../logger/core.js';
import { joinPaths, prependForwardSlash } from '../path.js'; import { joinPaths, prependForwardSlash } from '../path.js';
import { render } from '../render/core.js'; import { createEnvironment, Environment, createRenderContext, renderPage } from '../render/index.js';
import { RouteCache } from '../render/route-cache.js'; import { RouteCache } from '../render/route-cache.js';
import { import {
createLinkStylesheetElementSet, createLinkStylesheetElementSet,
@ -31,16 +31,15 @@ export interface MatchOptions {
} }
export class App { export class App {
#env: Environment;
#manifest: Manifest; #manifest: Manifest;
#manifestData: ManifestData; #manifestData: ManifestData;
#routeDataToRouteInfo: Map<RouteData, RouteInfo>; #routeDataToRouteInfo: Map<RouteData, RouteInfo>;
#routeCache: RouteCache;
#encoder = new TextEncoder(); #encoder = new TextEncoder();
#logging: LogOptions = { #logging: LogOptions = {
dest: consoleLogDestination, dest: consoleLogDestination,
level: 'info', level: 'info',
}; };
#streaming: boolean;
constructor(manifest: Manifest, streaming = true) { constructor(manifest: Manifest, streaming = true) {
this.#manifest = manifest; this.#manifest = manifest;
@ -48,8 +47,32 @@ export class App {
routes: manifest.routes.map((route) => route.routeData), routes: manifest.routes.map((route) => route.routeData),
}; };
this.#routeDataToRouteInfo = new Map(manifest.routes.map((route) => [route.routeData, route])); this.#routeDataToRouteInfo = new Map(manifest.routes.map((route) => [route.routeData, route]));
this.#routeCache = new RouteCache(this.#logging); this.#env = createEnvironment({
this.#streaming = streaming; adapterName: manifest.adapterName,
logging: this.#logging,
markdown: manifest.markdown,
mode: 'production',
renderers: manifest.renderers,
async resolve(specifier: string) {
if (!(specifier in manifest.entryModules)) {
throw new Error(`Unable to resolve [${specifier}]`);
}
const bundlePath = manifest.entryModules[specifier];
switch (true) {
case bundlePath.startsWith('data:'):
case bundlePath.length === 0: {
return bundlePath;
}
default: {
return prependForwardSlash(joinPaths(manifest.base, bundlePath));
}
}
},
routeCache: new RouteCache(this.#logging),
site: this.#manifest.site,
ssr: true,
streaming,
});
} }
match(request: Request, { matchNotFound = false }: MatchOptions = {}): RouteData | undefined { match(request: Request, { matchNotFound = false }: MatchOptions = {}): RouteData | undefined {
const url = new URL(request.url); const url = new URL(request.url);
@ -148,41 +171,17 @@ export class App {
} }
try { try {
const response = await render({ const ctx = createRenderContext({
adapterName: manifest.adapterName, request,
links,
logging: this.#logging,
markdown: manifest.markdown,
mod,
mode: 'production',
origin: url.origin, origin: url.origin,
pathname: url.pathname, pathname: url.pathname,
scripts, scripts,
renderers, links,
async resolve(specifier: string) {
if (!(specifier in manifest.entryModules)) {
throw new Error(`Unable to resolve [${specifier}]`);
}
const bundlePath = manifest.entryModules[specifier];
switch (true) {
case bundlePath.startsWith('data:'):
case bundlePath.length === 0: {
return bundlePath;
}
default: {
return prependForwardSlash(joinPaths(manifest.base, bundlePath));
}
}
},
route: routeData, route: routeData,
routeCache: this.#routeCache,
site: this.#manifest.site,
ssr: true,
request,
streaming: this.#streaming,
status, status,
}); });
const response = await renderPage(mod, ctx, this.#env);
return response; return response;
} catch (err: any) { } catch (err: any) {
error(this.#logging, 'ssr', err.stack || err.message || String(err)); error(this.#logging, 'ssr', err.stack || err.message || String(err));
@ -201,17 +200,17 @@ export class App {
): Promise<Response> { ): Promise<Response> {
const url = new URL(request.url); const url = new URL(request.url);
const handler = mod as unknown as EndpointHandler; const handler = mod as unknown as EndpointHandler;
const result = await callEndpoint(handler, {
logging: this.#logging, const ctx = createRenderContext({
request,
origin: url.origin, origin: url.origin,
pathname: url.pathname, pathname: url.pathname,
request,
route: routeData, route: routeData,
routeCache: this.#routeCache,
ssr: true,
status, status,
}); });
const result = await callEndpoint(handler, this.#env, ctx);
if (result.type === 'response') { if (result.type === 'response') {
if (result.response.headers.get('X-Astro-Response') === 'Not-Found') { if (result.response.headers.get('X-Astro-Response') === 'Not-Found') {
const fourOhFourRequest = new Request(new URL('/404', request.url)); const fourOhFourRequest = new Request(new URL('/404', request.url));

View file

@ -19,12 +19,11 @@ import {
removeLeadingForwardSlash, removeLeadingForwardSlash,
removeTrailingForwardSlash, removeTrailingForwardSlash,
} from '../../core/path.js'; } from '../../core/path.js';
import type { RenderOptions } from '../../core/render/core';
import { runHookBuildGenerated } from '../../integrations/index.js'; import { runHookBuildGenerated } from '../../integrations/index.js';
import { BEFORE_HYDRATION_SCRIPT_ID, PAGE_SCRIPT_ID } from '../../vite-plugin-scripts/index.js'; import { BEFORE_HYDRATION_SCRIPT_ID, PAGE_SCRIPT_ID } from '../../vite-plugin-scripts/index.js';
import { call as callEndpoint } from '../endpoint/index.js'; import { call as callEndpoint } from '../endpoint/index.js';
import { debug, info } from '../logger/core.js'; import { debug, info } from '../logger/core.js';
import { render } from '../render/core.js'; import { createEnvironment, createRenderContext, renderPage } from '../render/index.js';
import { callGetStaticPaths } from '../render/route-cache.js'; import { callGetStaticPaths } from '../render/route-cache.js';
import { createLinkStylesheetElementSet, createModuleScriptsSet } from '../render/ssr-element.js'; import { createLinkStylesheetElementSet, createModuleScriptsSet } from '../render/ssr-element.js';
import { createRequest } from '../request.js'; import { createRequest } from '../request.js';
@ -360,19 +359,14 @@ async function generatePath(
opts.settings.config.build.format, opts.settings.config.build.format,
pageData.route.type pageData.route.type
); );
const options: RenderOptions = { const env = createEnvironment({
adapterName: undefined, adapterName: undefined,
links,
logging, logging,
markdown: { markdown: {
...settings.config.markdown, ...settings.config.markdown,
isAstroFlavoredMd: settings.config.legacy.astroFlavoredMarkdown, isAstroFlavoredMd: settings.config.legacy.astroFlavoredMarkdown,
}, },
mod,
mode: opts.mode, mode: opts.mode,
origin,
pathname,
scripts,
renderers, renderers,
async resolve(specifier: string) { async resolve(specifier: string) {
const hashedFilePath = internals.entrySpecifierToBundleMap.get(specifier); const hashedFilePath = internals.entrySpecifierToBundleMap.get(specifier);
@ -386,20 +380,27 @@ async function generatePath(
} }
return prependForwardSlash(npath.posix.join(settings.config.base, hashedFilePath)); return prependForwardSlash(npath.posix.join(settings.config.base, hashedFilePath));
}, },
request: createRequest({ url, headers: new Headers(), logging, ssr }),
route: pageData.route,
routeCache, routeCache,
site: settings.config.site site: settings.config.site
? new URL(settings.config.base, settings.config.site).toString() ? new URL(settings.config.base, settings.config.site).toString()
: settings.config.site, : settings.config.site,
ssr, ssr,
streaming: true, streaming: true,
}; });
const ctx = createRenderContext({
origin,
pathname,
request: createRequest({ url, headers: new Headers(), logging, ssr }),
scripts,
links,
route: pageData.route,
});
let body: string; let body: string;
let encoding: BufferEncoding | undefined; let encoding: BufferEncoding | undefined;
if (pageData.route.type === 'endpoint') { if (pageData.route.type === 'endpoint') {
const result = await callEndpoint(mod as unknown as EndpointHandler, options); const endpointHandler = mod as unknown as EndpointHandler;
const result = await callEndpoint(endpointHandler, env, ctx);
if (result.type === 'response') { if (result.type === 'response') {
throw new Error(`Returning a Response from an endpoint is not supported in SSG mode.`); throw new Error(`Returning a Response from an endpoint is not supported in SSG mode.`);
@ -407,7 +408,7 @@ async function generatePath(
body = result.body; body = result.body;
encoding = result.encoding; encoding = result.encoding;
} else { } else {
const response = await render(options); const response = await renderPage(mod, ctx, env);
// If there's a redirect or something, just do nothing. // If there's a redirect or something, just do nothing.
if (response.status !== 200 || !response.body) { if (response.status !== 200 || !response.body) {

View file

@ -1,14 +1,18 @@
import type { EndpointHandler } from '../../../@types/astro'; import type { EndpointHandler } from '../../../@types/astro';
import type { SSROptions } from '../../render/dev'; import type { SSROptions } from '../../render/dev';
import { preload } from '../../render/dev/index.js'; import { createRenderContext } from '../../render/index.js';
import { call as callEndpoint } from '../index.js'; import { call as callEndpoint } from '../index.js';
export async function call(ssrOpts: SSROptions) { export async function call(options: SSROptions) {
const [, mod] = await preload(ssrOpts); const { env, preload: [,mod] } = options;
return await callEndpoint(mod as unknown as EndpointHandler, { const endpointHandler = mod as unknown as EndpointHandler;
...ssrOpts,
ssr: ssrOpts.settings.config.output === 'server', const ctx = createRenderContext({
site: ssrOpts.settings.config.site, request: options.request,
adapterName: ssrOpts.settings.config.adapter?.name, origin: options.origin,
pathname: options.pathname,
route: options.route
}); });
return await callEndpoint(endpointHandler, env, ctx);
} }

View file

@ -1,5 +1,5 @@
import type { APIContext, EndpointHandler, Params } from '../../@types/astro'; import type { APIContext, EndpointHandler, Params } from '../../@types/astro';
import type { RenderOptions } from '../render/core'; import type { Environment, RenderContext } from '../render/index';
import { renderEndpoint } from '../../runtime/server/index.js'; import { renderEndpoint } from '../../runtime/server/index.js';
import { ASTRO_VERSION } from '../constants.js'; import { ASTRO_VERSION } from '../constants.js';
@ -8,21 +8,6 @@ import { getParamsAndProps, GetParamsAndPropsError } from '../render/core.js';
const clientAddressSymbol = Symbol.for('astro.clientAddress'); const clientAddressSymbol = Symbol.for('astro.clientAddress');
export type EndpointOptions = Pick<
RenderOptions,
| 'logging'
| 'origin'
| 'request'
| 'route'
| 'routeCache'
| 'pathname'
| 'route'
| 'site'
| 'ssr'
| 'status'
| 'adapterName'
>;
type EndpointCallResult = type EndpointCallResult =
| { | {
type: 'simple'; type: 'simple';
@ -83,25 +68,34 @@ function createAPIContext({
export async function call( export async function call(
mod: EndpointHandler, mod: EndpointHandler,
opts: EndpointOptions env: Environment,
ctx: RenderContext
): Promise<EndpointCallResult> { ): Promise<EndpointCallResult> {
const paramsAndPropsResp = await getParamsAndProps({ ...opts, mod: mod as any }); const paramsAndPropsResp = await getParamsAndProps({
mod: mod as any,
route: ctx.route,
routeCache: env.routeCache,
pathname: ctx.pathname,
logging: env.logging,
ssr: env.ssr
});
if (paramsAndPropsResp === GetParamsAndPropsError.NoMatchingStaticPath) { if (paramsAndPropsResp === GetParamsAndPropsError.NoMatchingStaticPath) {
throw new Error( throw new Error(
`[getStaticPath] route pattern matched, but no matching static path found. (${opts.pathname})` `[getStaticPath] route pattern matched, but no matching static path found. (${ctx.pathname})`
); );
} }
const [params, props] = paramsAndPropsResp; const [params, props] = paramsAndPropsResp;
const context = createAPIContext({ const context = createAPIContext({
request: opts.request, request: ctx.request,
params, params,
props, props,
site: opts.site, site: env.site,
adapterName: opts.adapterName, adapterName: env.adapterName,
}); });
const response = await renderEndpoint(mod, context, opts.ssr);
const response = await renderEndpoint(mod, context, env.ssr);
if (response instanceof Response) { if (response instanceof Response) {
attachToResponse(response, context.cookies); attachToResponse(response, context.cookies);

View file

@ -0,0 +1,45 @@
import type { MarkdownRenderingOptions } from '@astrojs/markdown-remark';
import type {
ComponentInstance,
Params,
Props,
RouteData,
RuntimeMode,
SSRElement,
SSRLoadedRenderer,
} from '../../@types/astro';
import type { LogOptions } from '../logger/core.js';
import type { Environment } from './environment.js';
/**
* The RenderContext represents the parts of rendering that are specific to one request.
*/
export interface RenderContext {
request: Request;
origin: string;
pathname: string;
url: URL;
scripts?: Set<SSRElement>;
links?: Set<SSRElement>;
styles?: Set<SSRElement>;
route?: RouteData;
status?: number;
}
export type CreateRenderContextArgs = Partial<RenderContext> & {
origin?: string;
request: RenderContext['request'];
}
export function createRenderContext(options: CreateRenderContextArgs): RenderContext {
const request = options.request;
const url = new URL(request.url);
const origin = options.origin ?? url.origin;
const pathname = options.pathname ?? url.pathname;
return {
...options,
origin,
pathname,
url
};
}

View file

@ -1,16 +1,14 @@
import type { MarkdownRenderingOptions } from '@astrojs/markdown-remark';
import type { import type {
ComponentInstance, ComponentInstance,
Params, Params,
Props, Props,
RouteData, RouteData,
RuntimeMode,
SSRElement,
SSRLoadedRenderer,
} from '../../@types/astro'; } from '../../@types/astro';
import type { LogOptions } from '../logger/core.js'; import type { LogOptions } from '../logger/core.js';
import type { Environment } from './environment.js';
import type { RenderContext } from './context.js';
import { Fragment, renderPage } from '../../runtime/server/index.js'; import { Fragment, renderPage as runtimeRenderPage } from '../../runtime/server/index.js';
import { attachToResponse } from '../cookies/index.js'; import { attachToResponse } from '../cookies/index.js';
import { getParams } from '../routing/params.js'; import { getParams } from '../routing/params.js';
import { createResult } from './result.js'; import { createResult } from './result.js';
@ -67,90 +65,46 @@ export async function getParamsAndProps(
return [params, pageProps]; return [params, pageProps];
} }
export interface RenderOptions { export async function renderPage(mod: ComponentInstance, ctx: RenderContext, env: Environment) {
adapterName?: string;
logging: LogOptions;
links: Set<SSRElement>;
styles?: Set<SSRElement>;
markdown: MarkdownRenderingOptions;
mod: ComponentInstance;
mode: RuntimeMode;
origin: string;
pathname: string;
scripts: Set<SSRElement>;
resolve: (s: string) => Promise<string>;
renderers: SSRLoadedRenderer[];
route?: RouteData;
routeCache: RouteCache;
site?: string;
ssr: boolean;
streaming: boolean;
request: Request;
status?: number;
}
export async function render(opts: RenderOptions): Promise<Response> {
const {
adapterName,
links,
styles,
logging,
origin,
markdown,
mod,
mode,
pathname,
scripts,
renderers,
request,
resolve,
route,
routeCache,
site,
ssr,
streaming,
status = 200,
} = opts;
const paramsAndPropsRes = await getParamsAndProps({ const paramsAndPropsRes = await getParamsAndProps({
logging, logging: env.logging,
mod, mod,
route, route: ctx.route,
routeCache, routeCache: env.routeCache,
pathname, pathname: ctx.pathname,
ssr, ssr: env.ssr,
}); });
if (paramsAndPropsRes === GetParamsAndPropsError.NoMatchingStaticPath) { if (paramsAndPropsRes === GetParamsAndPropsError.NoMatchingStaticPath) {
throw new Error( throw new Error(
`[getStaticPath] route pattern matched, but no matching static path found. (${pathname})` `[getStaticPath] route pattern matched, but no matching static path found. (${ctx.pathname})`
); );
} }
const [params, pageProps] = paramsAndPropsRes; const [params, pageProps] = paramsAndPropsRes;
// Validate the page component before rendering the page // Validate the page component before rendering the page
const Component = await mod.default; const Component = mod.default;
if (!Component) if (!Component)
throw new Error(`Expected an exported Astro component but received typeof ${typeof Component}`); throw new Error(`Expected an exported Astro component but received typeof ${typeof Component}`);
const result = createResult({ const result = createResult({
adapterName, adapterName: env.adapterName,
links, links: ctx.links,
styles, styles: ctx.styles,
logging, logging: env.logging,
markdown, markdown: env.markdown,
mode, mode: env.mode,
origin, origin: ctx.origin,
params, params,
props: pageProps, props: pageProps,
pathname, pathname: ctx.pathname,
resolve, resolve: env.resolve,
renderers, renderers: env.renderers,
request, request: ctx.request,
site, site: env.site,
scripts, scripts: ctx.scripts,
ssr, ssr: env.ssr,
status, status: ctx.status ?? 200,
}); });
// Support `export const components` for `MDX` pages // Support `export const components` for `MDX` pages
@ -165,7 +119,7 @@ export async function render(opts: RenderOptions): Promise<Response> {
}); });
} }
const response = await renderPage(result, Component, pageProps, null, streaming); const response = await runtimeRenderPage(result, Component, pageProps, null, env.streaming);
// If there is an Astro.cookies instance, attach it to the response so that // If there is an Astro.cookies instance, attach it to the response so that
// adapters can grab the Set-Cookie headers. // adapters can grab the Set-Cookie headers.

View file

@ -0,0 +1,47 @@
import type { MarkdownRenderingOptions } from '@astrojs/markdown-remark';
import type { ViteDevServer } from 'vite';
import type {
AstroSettings,
RuntimeMode,
SSRLoadedRenderer,
} from '../../../@types/astro';
import type { Environment } from '../index';
import type { LogOptions } from '../../logger/core.js';
import { RouteCache } from '../route-cache.js';
import { createEnvironment } from '../index.js';
import { createResolve } from './resolve.js';
export type DevelopmentEnvironment = Environment & {
settings: AstroSettings;
viteServer: ViteDevServer;
}
export function createDevelopmentEnvironment(
settings: AstroSettings,
logging: LogOptions,
viteServer: ViteDevServer
): DevelopmentEnvironment {
const mode: RuntimeMode = 'development';
let env = createEnvironment({
adapterName: settings.adapter?.name,
logging,
markdown: {
...settings.config.markdown,
isAstroFlavoredMd: settings.config.legacy.astroFlavoredMarkdown,
},
mode,
// This will be overridden in the dev server
renderers: [],
resolve: createResolve(viteServer),
routeCache: new RouteCache(logging, mode),
site: settings.config.site,
ssr: settings.config.output === 'server',
streaming: true,
});
return {
...env,
viteServer,
settings
};
}

View file

@ -1,7 +1,6 @@
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import type { ViteDevServer } from 'vite'; import type { ViteDevServer } from 'vite';
import type { import type {
AstroRenderer,
AstroSettings, AstroSettings,
ComponentInstance, ComponentInstance,
RouteData, RouteData,
@ -9,16 +8,20 @@ import type {
SSRElement, SSRElement,
SSRLoadedRenderer, SSRLoadedRenderer,
} from '../../../@types/astro'; } from '../../../@types/astro';
import type { DevelopmentEnvironment } from './environment';
import { PAGE_SCRIPT_ID } from '../../../vite-plugin-scripts/index.js'; import { PAGE_SCRIPT_ID } from '../../../vite-plugin-scripts/index.js';
import { LogOptions } from '../../logger/core.js'; import { LogOptions } from '../../logger/core.js';
import { isPage, resolveIdToUrl } from '../../util.js'; import { isPage, resolveIdToUrl } from '../../util.js';
import { render as coreRender } from '../core.js'; import { renderPage as coreRenderPage, createRenderContext } from '../index.js';
import { RouteCache } from '../route-cache.js'; import { RouteCache } from '../route-cache.js';
import { collectMdMetadata } from '../util.js'; import { collectMdMetadata } from '../util.js';
import { getStylesForURL } from './css.js'; import { getStylesForURL } from './css.js';
import { getScriptsForURL } from './scripts.js'; import { getScriptsForURL } from './scripts.js';
import { loadRenderer, filterFoundRenderers } from '../renderer.js';
export { createDevelopmentEnvironment } from './environment.js';
export type { DevelopmentEnvironment };
export interface SSROptions { export interface SSROptionsOld {
/** an instance of the AstroSettings */ /** an instance of the AstroSettings */
settings: AstroSettings; settings: AstroSettings;
/** location of file on disk */ /** location of file on disk */
@ -41,72 +44,81 @@ export interface SSROptions {
request: Request; request: Request;
} }
export type ComponentPreload = [SSRLoadedRenderer[], ComponentInstance]; /*
filePath: options.filePath
});
const svelteStylesRE = /svelte\?svelte&type=style/; const ctx = createRenderContext({
request: options.request,
origin: options.origin,
pathname: options.pathname,
scripts,
links,
styles,
route: options.route
*/
export interface SSROptions {
/** The environment instance */
env: DevelopmentEnvironment;
/** location of file on disk */
filePath: URL;
/** production website */
origin: string;
/** the web request (needed for dynamic routes) */
pathname: string;
/** The renderers and instance */
preload: ComponentPreload;
/** Request */
request: Request;
/** optional, in case we need to render something outside of a dev server */
route?: RouteData;
async function loadRenderer(
viteServer: ViteDevServer,
renderer: AstroRenderer
): Promise<SSRLoadedRenderer> {
const mod = (await viteServer.ssrLoadModule(renderer.serverEntrypoint)) as {
default: SSRLoadedRenderer['ssr'];
};
return { ...renderer, ssr: mod.default };
} }
export type ComponentPreload = [SSRLoadedRenderer[], ComponentInstance];
export async function loadRenderers( export async function loadRenderers(
viteServer: ViteDevServer, viteServer: ViteDevServer,
settings: AstroSettings settings: AstroSettings
): Promise<SSRLoadedRenderer[]> { ): Promise<SSRLoadedRenderer[]> {
return Promise.all(settings.renderers.map((r) => loadRenderer(viteServer, r))); const loader = (entry: string) => viteServer.ssrLoadModule(entry);
const renderers = await Promise.all(settings.renderers.map(r => loadRenderer(r, loader)));
return filterFoundRenderers(renderers);
} }
export async function preload({ export async function preload({
settings, env,
filePath, filePath,
viteServer, }: Pick<SSROptions, 'env' | 'filePath'>): Promise<ComponentPreload> {
}: Pick<SSROptions, 'settings' | 'filePath' | 'viteServer'>): Promise<ComponentPreload> {
// Important: This needs to happen first, in case a renderer provides polyfills. // Important: This needs to happen first, in case a renderer provides polyfills.
const renderers = await loadRenderers(viteServer, settings); const renderers = await loadRenderers(env.viteServer, env.settings);
// Load the module from the Vite SSR Runtime. // Load the module from the Vite SSR Runtime.
const mod = (await viteServer.ssrLoadModule(fileURLToPath(filePath))) as ComponentInstance; const mod = (await env.viteServer.ssrLoadModule(fileURLToPath(filePath))) as ComponentInstance;
if (viteServer.config.mode === 'development' || !mod?.$$metadata) { if (env.viteServer.config.mode === 'development' || !mod?.$$metadata) {
return [renderers, mod]; return [renderers, mod];
} }
// append all nested markdown metadata to mod.$$metadata // append all nested markdown metadata to mod.$$metadata
const modGraph = await viteServer.moduleGraph.getModuleByUrl(fileURLToPath(filePath)); const modGraph = await env.viteServer.moduleGraph.getModuleByUrl(fileURLToPath(filePath));
if (modGraph) { if (modGraph) {
await collectMdMetadata(mod.$$metadata, modGraph, viteServer); await collectMdMetadata(mod.$$metadata, modGraph, env.viteServer);
} }
return [renderers, mod]; return [renderers, mod];
} }
/** use Vite to SSR */ interface GetScriptsAndStylesParams {
export async function render( env: DevelopmentEnvironment;
renderers: SSRLoadedRenderer[], filePath: URL;
mod: ComponentInstance, }
ssrOpts: SSROptions
): Promise<Response> { async function getScriptsAndStyles({ env, filePath }: GetScriptsAndStylesParams) {
const {
settings,
filePath,
logging,
mode,
origin,
pathname,
request,
route,
routeCache,
viteServer,
} = ssrOpts;
// Add hoisted script tags // Add hoisted script tags
const scripts = await getScriptsForURL(filePath, viteServer); const scripts = await getScriptsForURL(filePath, env.viteServer);
// Inject HMR scripts // Inject HMR scripts
if (isPage(filePath, settings) && mode === 'development') { if (isPage(filePath, env.settings) && env.mode === 'development') {
scripts.add({ scripts.add({
props: { type: 'module', src: '/@vite/client' }, props: { type: 'module', src: '/@vite/client' },
children: '', children: '',
@ -114,20 +126,20 @@ export async function render(
scripts.add({ scripts.add({
props: { props: {
type: 'module', type: 'module',
src: await resolveIdToUrl(viteServer, 'astro/runtime/client/hmr.js'), src: await resolveIdToUrl(env.viteServer, 'astro/runtime/client/hmr.js'),
}, },
children: '', children: '',
}); });
} }
// TODO: We should allow adding generic HTML elements to the head, not just scripts // TODO: We should allow adding generic HTML elements to the head, not just scripts
for (const script of settings.scripts) { for (const script of env.settings.scripts) {
if (script.stage === 'head-inline') { if (script.stage === 'head-inline') {
scripts.add({ scripts.add({
props: {}, props: {},
children: script.content, children: script.content,
}); });
} else if (script.stage === 'page' && isPage(filePath, settings)) { } else if (script.stage === 'page' && isPage(filePath, env.settings)) {
scripts.add({ scripts.add({
props: { type: 'module', src: `/@id/${PAGE_SCRIPT_ID}` }, props: { type: 'module', src: `/@id/${PAGE_SCRIPT_ID}` },
children: '', children: '',
@ -136,7 +148,7 @@ export async function render(
} }
// Pass framework CSS in as style tags to be appended to the page. // Pass framework CSS in as style tags to be appended to the page.
const { urls: styleUrls, stylesMap } = await getStylesForURL(filePath, viteServer, mode); const { urls: styleUrls, stylesMap } = await getStylesForURL(filePath, env.viteServer, env.mode);
let links = new Set<SSRElement>(); let links = new Set<SSRElement>();
[...styleUrls].forEach((href) => { [...styleUrls].forEach((href) => {
links.add({ links.add({
@ -164,54 +176,31 @@ export async function render(
children: content, children: content,
}); });
}); });
return { scripts, styles, links };
}
let response = await coreRender({ export async function renderPage(options: SSROptions): Promise<Response> {
adapterName: settings.config.adapter?.name, const [renderers, mod] = options.preload;
links,
styles, // Override the environment's renderers. This ensures that if renderers change (HMR)
logging, // The new instances are passed through.
markdown: { options.env.renderers = renderers;
...settings.config.markdown,
isAstroFlavoredMd: settings.config.legacy.astroFlavoredMarkdown, const { scripts, links, styles } = await getScriptsAndStyles({
}, env: options.env,
mod, filePath: options.filePath
mode,
origin,
pathname,
scripts,
// Resolves specifiers in the inline hydrated scripts, such as:
// - @astrojs/preact/client.js
// - @/components/Foo.vue
// - /Users/macos/project/src/Foo.vue
// - C:/Windows/project/src/Foo.vue (normalized slash)
async resolve(s: string) {
const url = await resolveIdToUrl(viteServer, s);
// Vite does not resolve .jsx -> .tsx when coming from hydration script import,
// clip it so Vite is able to resolve implicitly.
if (url.startsWith('/@fs') && url.endsWith('.jsx')) {
return url.slice(0, -4);
} else {
return url;
}
},
renderers,
request,
route,
routeCache,
site: settings.config.site
? new URL(settings.config.base, settings.config.site).toString()
: undefined,
ssr: settings.config.output === 'server',
streaming: true,
}); });
return response; const ctx = createRenderContext({
} request: options.request,
origin: options.origin,
pathname: options.pathname,
scripts,
links,
styles,
route: options.route
});
export async function ssr( return await coreRenderPage(mod, ctx, options.env); // NOTE: without "await", errors wont get caught below
preloadedComponent: ComponentPreload,
ssrOpts: SSROptions
): Promise<Response> {
const [renderers, mod] = preloadedComponent;
return await render(renderers, mod, ssrOpts); // NOTE: without "await", errors wont get caught below
} }

View file

@ -0,0 +1,20 @@
import type { ViteDevServer } from 'vite';
import { isPage, resolveIdToUrl } from '../../util.js';
export function createResolve(viteServer: ViteDevServer) {
// Resolves specifiers in the inline hydrated scripts, such as:
// - @astrojs/preact/client.js
// - @/components/Foo.vue
// - /Users/macos/project/src/Foo.vue
// - C:/Windows/project/src/Foo.vue (normalized slash)
return async function(s: string) {
const url = await resolveIdToUrl(viteServer, s);
// Vite does not resolve .jsx -> .tsx when coming from hydration script import,
// clip it so Vite is able to resolve implicitly.
if (url.startsWith('/@fs') && url.endsWith('.jsx')) {
return url.slice(0, -4);
} else {
return url;
}
};
}

View file

@ -0,0 +1,52 @@
import type { MarkdownRenderingOptions } from '@astrojs/markdown-remark';
import type {
RuntimeMode,
SSRLoadedRenderer,
} from '../../@types/astro';
import type { LogOptions } from '../logger/core.js';
import { RouteCache } from './route-cache.js';
/**
* An environment represents the static parts of rendering that do not change
* between requests. These are mostly known when the server first starts up and do not change.
* Thus they can be created once and passed through to renderPage on each request.
*/
export interface Environment {
adapterName?: string;
/** logging options */
logging: LogOptions;
markdown: MarkdownRenderingOptions;
/** "development" or "production" */
mode: RuntimeMode;
renderers: SSRLoadedRenderer[];
resolve: (s: string) => Promise<string>;
routeCache: RouteCache;
site?: string;
ssr: boolean;
streaming: boolean;
}
export type CreateEnvironmentArgs = Environment;
export function createEnvironment(options: CreateEnvironmentArgs): Environment {
return options;
}
export type CreateBasicEnvironmentArgs = Partial<Environment> & {
logging: CreateEnvironmentArgs['logging'];
}
export function createBasicEnvironment(options: CreateBasicEnvironmentArgs): Environment {
const mode = options.mode ?? 'development';
return createEnvironment({
...options,
markdown: options.markdown ?? {},
mode,
renderers: options.renderers ?? [],
resolve: options.resolve ?? ((s: string) => Promise.resolve(s)),
routeCache: new RouteCache(options.logging, mode),
ssr: options.ssr ?? true,
streaming: options.streaming ?? true
});
}

View file

@ -0,0 +1,22 @@
export type {
Environment
} from './environment';
export type {
RenderContext
} from './context';
export {
createBasicEnvironment,
createEnvironment
} from './environment.js';
export {
createRenderContext
} from './context.js';
export {
getParamsAndProps,
GetParamsAndPropsError,
renderPage,
} from './core.js';
export {
loadRenderer
} from './renderer.js';

View file

@ -0,0 +1,28 @@
import type { AstroRenderer, SSRLoadedRenderer } from '../../@types/astro';
export type RendererServerEntrypointModule = {
default: SSRLoadedRenderer['ssr'];
};
export type MaybeRendererServerEntrypointModule = Partial<RendererServerEntrypointModule>;
export type RendererLoader = (entryPoint: string) => Promise<MaybeRendererServerEntrypointModule>;
export async function loadRenderer(renderer: AstroRenderer, loader: RendererLoader): Promise<SSRLoadedRenderer | undefined> {
const mod = await loader(renderer.serverEntrypoint);
if(typeof mod.default !== 'undefined') {
return createLoadedRenderer(renderer, mod as RendererServerEntrypointModule);
}
return undefined;
}
export function filterFoundRenderers(renderers: Array<SSRLoadedRenderer | undefined>): SSRLoadedRenderer[] {
return renderers.filter((renderer): renderer is SSRLoadedRenderer => {
return !!renderer;
});
}
export function createLoadedRenderer(renderer: AstroRenderer, mod: RendererServerEntrypointModule): SSRLoadedRenderer {
return {
...renderer,
ssr: mod.default
};
}

View file

@ -129,7 +129,7 @@ class Slots {
let renderMarkdown: any = null; let renderMarkdown: any = null;
export function createResult(args: CreateResultArgs): SSRResult { export function createResult(args: CreateResultArgs): SSRResult {
const { markdown, params, pathname, props: pageProps, renderers, request, resolve } = args; const { markdown, params, pathname, renderers, request, resolve } = args;
const url = new URL(request.url); const url = new URL(request.url);
const headers = new Headers(); const headers = new Headers();

View file

@ -0,0 +1,9 @@
import renderer from './renderer.js';
import { __astro_tag_component__ } from '../runtime/server/index.js';
const ASTRO_JSX_RENDERER_NAME = renderer.name;
export function createAstroJSXComponent(factory: (...args: any[]) => any) {
__astro_tag_component__(factory, ASTRO_JSX_RENDERER_NAME);
return factory;
}

View file

@ -0,0 +1,6 @@
export {
default as renderer
} from './renderer.js';
export {
createAstroJSXComponent
} from './component.js';

View file

@ -21,6 +21,7 @@ export {
stringifyChunk, stringifyChunk,
voidElementNames, voidElementNames,
} from './render/index.js'; } from './render/index.js';
export { renderJSX } from './jsx.js';
export type { AstroComponentFactory, RenderInstruction } from './render/index.js'; export type { AstroComponentFactory, RenderInstruction } from './render/index.js';
import type { AstroComponentFactory } from './render/index.js'; import type { AstroComponentFactory } from './render/index.js';

View file

@ -2,7 +2,7 @@ import type http from 'http';
import mime from 'mime'; import mime from 'mime';
import type * as vite from 'vite'; import type * as vite from 'vite';
import type { AstroSettings, ManifestData } from '../@types/astro'; import type { AstroSettings, ManifestData } from '../@types/astro';
import type { SSROptions } from '../core/render/dev/index'; import { DevelopmentEnvironment, SSROptions } from '../core/render/dev/index';
import { Readable } from 'stream'; import { Readable } from 'stream';
import { getSetCookiesFromResponse } from '../core/cookies/index.js'; import { getSetCookiesFromResponse } from '../core/cookies/index.js';
@ -16,9 +16,8 @@ import {
import { error, info, LogOptions, warn } from '../core/logger/core.js'; import { error, info, LogOptions, warn } from '../core/logger/core.js';
import * as msg from '../core/messages.js'; import * as msg from '../core/messages.js';
import { appendForwardSlash } from '../core/path.js'; import { appendForwardSlash } from '../core/path.js';
import { getParamsAndProps, GetParamsAndPropsError } from '../core/render/core.js'; import { getParamsAndProps, GetParamsAndPropsError } from '../core/render/index.js';
import { preload, ssr } from '../core/render/dev/index.js'; import { createDevelopmentEnvironment, preload, renderPage } from '../core/render/dev/index.js';
import { RouteCache } from '../core/render/route-cache.js';
import { createRequest } from '../core/request.js'; import { createRequest } from '../core/request.js';
import { createRouteManifest, matchAllRoutes } from '../core/routing/index.js'; import { createRouteManifest, matchAllRoutes } from '../core/routing/index.js';
import { resolvePages } from '../core/util.js'; import { resolvePages } from '../core/util.js';
@ -100,7 +99,6 @@ async function writeSSRResult(webResponse: Response, res: http.ServerResponse) {
async function handle404Response( async function handle404Response(
origin: string, origin: string,
settings: AstroSettings,
req: http.IncomingMessage, req: http.IncomingMessage,
res: http.ServerResponse res: http.ServerResponse
) { ) {
@ -187,17 +185,15 @@ export function baseMiddleware(
async function matchRoute( async function matchRoute(
pathname: string, pathname: string,
routeCache: RouteCache, env: DevelopmentEnvironment,
viteServer: vite.ViteDevServer,
logging: LogOptions,
manifest: ManifestData, manifest: ManifestData,
settings: AstroSettings
) { ) {
const { logging, settings, routeCache } = env;
const matches = matchAllRoutes(pathname, manifest); const matches = matchAllRoutes(pathname, manifest);
for await (const maybeRoute of matches) { for await (const maybeRoute of matches) {
const filePath = new URL(`./${maybeRoute.component}`, settings.config.root); const filePath = new URL(`./${maybeRoute.component}`, settings.config.root);
const preloadedComponent = await preload({ settings, filePath, viteServer }); const preloadedComponent = await preload({ env, filePath });
const [, mod] = preloadedComponent; const [, mod] = preloadedComponent;
// attempt to get static paths // attempt to get static paths
// if this fails, we have a bad URL match! // if this fails, we have a bad URL match!
@ -233,7 +229,7 @@ async function matchRoute(
if (custom404) { if (custom404) {
const filePath = new URL(`./${custom404.component}`, settings.config.root); const filePath = new URL(`./${custom404.component}`, settings.config.root);
const preloadedComponent = await preload({ settings, filePath, viteServer }); const preloadedComponent = await preload({ env, filePath });
const [, mod] = preloadedComponent; const [, mod] = preloadedComponent;
return { return {
@ -249,14 +245,12 @@ async function matchRoute(
/** The main logic to route dev server requests to pages in Astro. */ /** The main logic to route dev server requests to pages in Astro. */
async function handleRequest( async function handleRequest(
routeCache: RouteCache, env: DevelopmentEnvironment,
viteServer: vite.ViteDevServer,
logging: LogOptions,
manifest: ManifestData, manifest: ManifestData,
settings: AstroSettings,
req: http.IncomingMessage, req: http.IncomingMessage,
res: http.ServerResponse res: http.ServerResponse
) { ) {
const { settings, viteServer } = env;
const { config } = settings; const { config } = settings;
const origin = `${viteServer.config.server.https ? 'https' : 'http'}://${req.headers.host}`; const origin = `${viteServer.config.server.https ? 'https' : 'http'}://${req.headers.host}`;
const buildingToSSR = config.output === 'server'; const buildingToSSR = config.output === 'server';
@ -296,11 +290,8 @@ async function handleRequest(
try { try {
const matchedRoute = await matchRoute( const matchedRoute = await matchRoute(
pathname, pathname,
routeCache, env,
viteServer,
logging,
manifest, manifest,
settings
); );
filePath = matchedRoute?.filePath; filePath = matchedRoute?.filePath;
@ -310,18 +301,15 @@ async function handleRequest(
pathname, pathname,
body, body,
origin, origin,
routeCache, env,
viteServer,
manifest, manifest,
logging,
settings,
req, req,
res res
); );
} catch (_err) { } catch (_err) {
const err = fixViteErrorMessage(_err, viteServer, filePath); const err = fixViteErrorMessage(_err, viteServer, filePath);
const errorWithMetadata = collectErrorMetadata(err); const errorWithMetadata = collectErrorMetadata(err);
error(logging, null, msg.formatErrorMessage(errorWithMetadata)); error(env.logging, null, msg.formatErrorMessage(errorWithMetadata));
handle500Response(viteServer, origin, req, res, errorWithMetadata); handle500Response(viteServer, origin, req, res, errorWithMetadata);
} }
} }
@ -332,16 +320,14 @@ async function handleRoute(
pathname: string, pathname: string,
body: ArrayBuffer | undefined, body: ArrayBuffer | undefined,
origin: string, origin: string,
routeCache: RouteCache, env: DevelopmentEnvironment,
viteServer: vite.ViteDevServer,
manifest: ManifestData, manifest: ManifestData,
logging: LogOptions,
settings: AstroSettings,
req: http.IncomingMessage, req: http.IncomingMessage,
res: http.ServerResponse res: http.ServerResponse
): Promise<void> { ): Promise<void> {
const { logging, settings } = env;
if (!matchedRoute) { if (!matchedRoute) {
return handle404Response(origin, settings, req, res); return handle404Response(origin, req, res);
} }
const { config } = settings; const { config } = settings;
@ -365,23 +351,20 @@ async function handleRoute(
const paramsAndPropsRes = await getParamsAndProps({ const paramsAndPropsRes = await getParamsAndProps({
mod, mod,
route, route,
routeCache, routeCache: env.routeCache,
pathname: pathname, pathname: pathname,
logging, logging,
ssr: config.output === 'server', ssr: config.output === 'server',
}); });
const options: SSROptions = { const options: SSROptions = {
settings, env,
filePath, filePath,
logging, origin,
mode: 'development', preload: preloadedComponent,
origin, pathname,
pathname: pathname, request,
route, route
routeCache,
viteServer,
request,
}; };
// Route successfully matched! Render it. // Route successfully matched! Render it.
@ -391,11 +374,8 @@ async function handleRoute(
if (result.response.headers.get('X-Astro-Response') === 'Not-Found') { if (result.response.headers.get('X-Astro-Response') === 'Not-Found') {
const fourOhFourRoute = await matchRoute( const fourOhFourRoute = await matchRoute(
'/404', '/404',
routeCache, env,
viteServer, manifest
logging,
manifest,
settings
); );
return handleRoute( return handleRoute(
fourOhFourRoute, fourOhFourRoute,
@ -403,11 +383,8 @@ async function handleRoute(
'/404', '/404',
body, body,
origin, origin,
routeCache, env,
viteServer,
manifest, manifest,
logging,
settings,
req, req,
res res
); );
@ -427,7 +404,7 @@ async function handleRoute(
res.end(result.body); res.end(result.body);
} }
} else { } else {
const result = await ssr(preloadedComponent, options); const result = await renderPage(options);
return await writeSSRResult(result, res); return await writeSSRResult(result, res);
} }
} }
@ -436,11 +413,12 @@ export default function createPlugin({ settings, logging }: AstroPluginOptions):
return { return {
name: 'astro:server', name: 'astro:server',
configureServer(viteServer) { configureServer(viteServer) {
let routeCache = new RouteCache(logging, 'development'); let env = createDevelopmentEnvironment(settings, logging, viteServer);
let manifest: ManifestData = createRouteManifest({ settings }, logging); let manifest: ManifestData = createRouteManifest({ settings }, logging);
/** rebuild the route cache + manifest, as needed. */ /** rebuild the route cache + manifest, as needed. */
function rebuildManifest(needsManifestRebuild: boolean, file: string) { function rebuildManifest(needsManifestRebuild: boolean, file: string) {
routeCache.clearAll(); env.routeCache.clearAll();
if (needsManifestRebuild) { if (needsManifestRebuild) {
manifest = createRouteManifest({ settings }, logging); manifest = createRouteManifest({ settings }, logging);
} }
@ -461,7 +439,7 @@ export default function createPlugin({ settings, logging }: AstroPluginOptions):
if (!req.url || !req.method) { if (!req.url || !req.method) {
throw new Error('Incomplete request'); throw new Error('Incomplete request');
} }
handleRequest(routeCache, viteServer, logging, manifest, settings, req, res); handleRequest(env, manifest, req, res);
}); });
}; };
}, },

View file

@ -0,0 +1,48 @@
import { expect } from 'chai';
import { createComponent, render, renderSlot } from '../../../dist/runtime/server/index.js';
import { jsx } from '../../../dist/jsx-runtime/index.js';
import { createBasicEnvironment, createRenderContext, renderPage, loadRenderer } from '../../../dist/core/render/index.js';
import { createAstroJSXComponent, renderer as jsxRenderer } from '../../../dist/jsx/index.js';
import { defaultLogging as logging } from '../../test-utils.js';
const createAstroModule = AstroComponent => ({ default: AstroComponent });
const loadJSXRenderer = () => loadRenderer(jsxRenderer, s => import(s));
describe('core/render', () => {
describe('Astro JSX components', () => {
let env;
before(async () => {
env = createBasicEnvironment({
logging,
renderers: [await loadJSXRenderer()]
});
})
it('Can render slots', async () => {
const Wrapper = createComponent((result, _props, slots = {}) => {
return render`<div>${renderSlot(result, slots['myslot'])}</div>`;
});
const Page = createAstroJSXComponent(() => {
return jsx(Wrapper, {
children: [
jsx('p', {
slot: 'myslot',
className: 'n',
children: 'works'
})
]
})
});
const ctx = createRenderContext({ request: new Request('http://example.com/' )});
const response = await renderPage(createAstroModule(Page), ctx, env);
expect(response.status).to.equal(200);
const html = await response.text();
expect(html).to.include('<div><p class="n">works</p></div>');
});
});
});