From df453e420fa9a2e37b51b17403e67717b6e38264 Mon Sep 17 00:00:00 2001 From: Matthew Phillips Date: Thu, 13 Oct 2022 15:18:28 -0400 Subject: [PATCH] Refactor rendering for testing (#5071) * Refactor rendering for testing * Correctly pass the mod for endpoints --- packages/astro/src/core/app/index.ts | 75 ++++---- packages/astro/src/core/build/generate.ts | 27 +-- packages/astro/src/core/endpoint/dev/index.ts | 20 +- packages/astro/src/core/endpoint/index.ts | 40 ++-- packages/astro/src/core/render/context.ts | 45 +++++ packages/astro/src/core/render/core.ts | 100 +++------- .../astro/src/core/render/dev/environment.ts | 47 +++++ packages/astro/src/core/render/dev/index.ts | 173 ++++++++---------- packages/astro/src/core/render/dev/resolve.ts | 20 ++ packages/astro/src/core/render/environment.ts | 52 ++++++ packages/astro/src/core/render/index.ts | 22 +++ packages/astro/src/core/render/renderer.ts | 28 +++ packages/astro/src/core/render/result.ts | 2 +- packages/astro/src/jsx/component.ts | 9 + packages/astro/src/jsx/index.ts | 6 + packages/astro/src/runtime/server/index.ts | 1 + .../src/vite-plugin-astro-server/index.ts | 84 ++++----- packages/astro/test/units/render/jsx.test.js | 48 +++++ 18 files changed, 498 insertions(+), 301 deletions(-) create mode 100644 packages/astro/src/core/render/context.ts create mode 100644 packages/astro/src/core/render/dev/environment.ts create mode 100644 packages/astro/src/core/render/dev/resolve.ts create mode 100644 packages/astro/src/core/render/environment.ts create mode 100644 packages/astro/src/core/render/index.ts create mode 100644 packages/astro/src/core/render/renderer.ts create mode 100644 packages/astro/src/jsx/component.ts create mode 100644 packages/astro/src/jsx/index.ts create mode 100644 packages/astro/test/units/render/jsx.test.js diff --git a/packages/astro/src/core/app/index.ts b/packages/astro/src/core/app/index.ts index d4197839f..d08266fb1 100644 --- a/packages/astro/src/core/app/index.ts +++ b/packages/astro/src/core/app/index.ts @@ -14,7 +14,7 @@ import { call as callEndpoint } from '../endpoint/index.js'; import { consoleLogDestination } from '../logger/console.js'; import { error } from '../logger/core.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 { createLinkStylesheetElementSet, @@ -31,16 +31,15 @@ export interface MatchOptions { } export class App { + #env: Environment; #manifest: Manifest; #manifestData: ManifestData; #routeDataToRouteInfo: Map; - #routeCache: RouteCache; #encoder = new TextEncoder(); #logging: LogOptions = { dest: consoleLogDestination, level: 'info', }; - #streaming: boolean; constructor(manifest: Manifest, streaming = true) { this.#manifest = manifest; @@ -48,8 +47,32 @@ export class App { routes: manifest.routes.map((route) => route.routeData), }; this.#routeDataToRouteInfo = new Map(manifest.routes.map((route) => [route.routeData, route])); - this.#routeCache = new RouteCache(this.#logging); - this.#streaming = streaming; + this.#env = createEnvironment({ + 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 { const url = new URL(request.url); @@ -148,41 +171,17 @@ export class App { } try { - const response = await render({ - adapterName: manifest.adapterName, - links, - logging: this.#logging, - markdown: manifest.markdown, - mod, - mode: 'production', + const ctx = createRenderContext({ + request, origin: url.origin, pathname: url.pathname, scripts, - 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)); - } - } - }, + links, route: routeData, - routeCache: this.#routeCache, - site: this.#manifest.site, - ssr: true, - request, - streaming: this.#streaming, status, }); + const response = await renderPage(mod, ctx, this.#env); return response; } catch (err: any) { error(this.#logging, 'ssr', err.stack || err.message || String(err)); @@ -201,17 +200,17 @@ export class App { ): Promise { const url = new URL(request.url); const handler = mod as unknown as EndpointHandler; - const result = await callEndpoint(handler, { - logging: this.#logging, + + const ctx = createRenderContext({ + request, origin: url.origin, pathname: url.pathname, - request, route: routeData, - routeCache: this.#routeCache, - ssr: true, status, }); + const result = await callEndpoint(handler, this.#env, ctx); + if (result.type === 'response') { if (result.response.headers.get('X-Astro-Response') === 'Not-Found') { const fourOhFourRequest = new Request(new URL('/404', request.url)); diff --git a/packages/astro/src/core/build/generate.ts b/packages/astro/src/core/build/generate.ts index 97df739a0..8d2622cfe 100644 --- a/packages/astro/src/core/build/generate.ts +++ b/packages/astro/src/core/build/generate.ts @@ -19,12 +19,11 @@ import { removeLeadingForwardSlash, removeTrailingForwardSlash, } from '../../core/path.js'; -import type { RenderOptions } from '../../core/render/core'; import { runHookBuildGenerated } from '../../integrations/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 { 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 { createLinkStylesheetElementSet, createModuleScriptsSet } from '../render/ssr-element.js'; import { createRequest } from '../request.js'; @@ -360,19 +359,14 @@ async function generatePath( opts.settings.config.build.format, pageData.route.type ); - const options: RenderOptions = { + const env = createEnvironment({ adapterName: undefined, - links, logging, markdown: { ...settings.config.markdown, isAstroFlavoredMd: settings.config.legacy.astroFlavoredMarkdown, }, - mod, mode: opts.mode, - origin, - pathname, - scripts, renderers, async resolve(specifier: string) { const hashedFilePath = internals.entrySpecifierToBundleMap.get(specifier); @@ -386,20 +380,27 @@ async function generatePath( } return prependForwardSlash(npath.posix.join(settings.config.base, hashedFilePath)); }, - request: createRequest({ url, headers: new Headers(), logging, ssr }), - route: pageData.route, routeCache, site: settings.config.site ? new URL(settings.config.base, settings.config.site).toString() : settings.config.site, ssr, streaming: true, - }; + }); + const ctx = createRenderContext({ + origin, + pathname, + request: createRequest({ url, headers: new Headers(), logging, ssr }), + scripts, + links, + route: pageData.route, + }); let body: string; let encoding: BufferEncoding | undefined; 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') { 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; encoding = result.encoding; } else { - const response = await render(options); + const response = await renderPage(mod, ctx, env); // If there's a redirect or something, just do nothing. if (response.status !== 200 || !response.body) { diff --git a/packages/astro/src/core/endpoint/dev/index.ts b/packages/astro/src/core/endpoint/dev/index.ts index b27127119..59bc3e730 100644 --- a/packages/astro/src/core/endpoint/dev/index.ts +++ b/packages/astro/src/core/endpoint/dev/index.ts @@ -1,14 +1,18 @@ import type { EndpointHandler } from '../../../@types/astro'; 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'; -export async function call(ssrOpts: SSROptions) { - const [, mod] = await preload(ssrOpts); - return await callEndpoint(mod as unknown as EndpointHandler, { - ...ssrOpts, - ssr: ssrOpts.settings.config.output === 'server', - site: ssrOpts.settings.config.site, - adapterName: ssrOpts.settings.config.adapter?.name, +export async function call(options: SSROptions) { + const { env, preload: [,mod] } = options; + const endpointHandler = mod as unknown as EndpointHandler; + + const ctx = createRenderContext({ + request: options.request, + origin: options.origin, + pathname: options.pathname, + route: options.route }); + + return await callEndpoint(endpointHandler, env, ctx); } diff --git a/packages/astro/src/core/endpoint/index.ts b/packages/astro/src/core/endpoint/index.ts index c4c1686e6..e73f98306 100644 --- a/packages/astro/src/core/endpoint/index.ts +++ b/packages/astro/src/core/endpoint/index.ts @@ -1,5 +1,5 @@ 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 { ASTRO_VERSION } from '../constants.js'; @@ -8,21 +8,6 @@ import { getParamsAndProps, GetParamsAndPropsError } from '../render/core.js'; 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: 'simple'; @@ -83,25 +68,34 @@ function createAPIContext({ export async function call( mod: EndpointHandler, - opts: EndpointOptions + env: Environment, + ctx: RenderContext ): Promise { - 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) { 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 context = createAPIContext({ - request: opts.request, + request: ctx.request, params, props, - site: opts.site, - adapterName: opts.adapterName, + site: env.site, + adapterName: env.adapterName, }); - const response = await renderEndpoint(mod, context, opts.ssr); + + const response = await renderEndpoint(mod, context, env.ssr); if (response instanceof Response) { attachToResponse(response, context.cookies); diff --git a/packages/astro/src/core/render/context.ts b/packages/astro/src/core/render/context.ts new file mode 100644 index 000000000..02e9b9439 --- /dev/null +++ b/packages/astro/src/core/render/context.ts @@ -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; + links?: Set; + styles?: Set; + route?: RouteData; + status?: number; +} + +export type CreateRenderContextArgs = Partial & { + 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 + }; +} diff --git a/packages/astro/src/core/render/core.ts b/packages/astro/src/core/render/core.ts index 5b7a3122a..ed2f39634 100644 --- a/packages/astro/src/core/render/core.ts +++ b/packages/astro/src/core/render/core.ts @@ -1,16 +1,14 @@ -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'; +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 { getParams } from '../routing/params.js'; import { createResult } from './result.js'; @@ -67,90 +65,46 @@ export async function getParamsAndProps( return [params, pageProps]; } -export interface RenderOptions { - adapterName?: string; - logging: LogOptions; - links: Set; - styles?: Set; - markdown: MarkdownRenderingOptions; - mod: ComponentInstance; - mode: RuntimeMode; - origin: string; - pathname: string; - scripts: Set; - resolve: (s: string) => Promise; - renderers: SSRLoadedRenderer[]; - route?: RouteData; - routeCache: RouteCache; - site?: string; - ssr: boolean; - streaming: boolean; - request: Request; - status?: number; -} - -export async function render(opts: RenderOptions): Promise { - const { - adapterName, - links, - styles, - logging, - origin, - markdown, - mod, - mode, - pathname, - scripts, - renderers, - request, - resolve, - route, - routeCache, - site, - ssr, - streaming, - status = 200, - } = opts; - +export async function renderPage(mod: ComponentInstance, ctx: RenderContext, env: Environment) { const paramsAndPropsRes = await getParamsAndProps({ - logging, + logging: env.logging, mod, - route, - routeCache, - pathname, - ssr, + route: ctx.route, + routeCache: env.routeCache, + pathname: ctx.pathname, + ssr: env.ssr, }); if (paramsAndPropsRes === GetParamsAndPropsError.NoMatchingStaticPath) { 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; // Validate the page component before rendering the page - const Component = await mod.default; + const Component = mod.default; if (!Component) throw new Error(`Expected an exported Astro component but received typeof ${typeof Component}`); const result = createResult({ - adapterName, - links, - styles, - logging, - markdown, - mode, - origin, + adapterName: env.adapterName, + links: ctx.links, + styles: ctx.styles, + logging: env.logging, + markdown: env.markdown, + mode: env.mode, + origin: ctx.origin, params, props: pageProps, - pathname, - resolve, - renderers, - request, - site, - scripts, - ssr, - status, + pathname: ctx.pathname, + resolve: env.resolve, + renderers: env.renderers, + request: ctx.request, + site: env.site, + scripts: ctx.scripts, + ssr: env.ssr, + status: ctx.status ?? 200, }); // Support `export const components` for `MDX` pages @@ -165,7 +119,7 @@ export async function render(opts: RenderOptions): Promise { }); } - 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 // adapters can grab the Set-Cookie headers. diff --git a/packages/astro/src/core/render/dev/environment.ts b/packages/astro/src/core/render/dev/environment.ts new file mode 100644 index 000000000..3b8daec75 --- /dev/null +++ b/packages/astro/src/core/render/dev/environment.ts @@ -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 + }; +} diff --git a/packages/astro/src/core/render/dev/index.ts b/packages/astro/src/core/render/dev/index.ts index a5426b1b3..fb0482415 100644 --- a/packages/astro/src/core/render/dev/index.ts +++ b/packages/astro/src/core/render/dev/index.ts @@ -1,7 +1,6 @@ import { fileURLToPath } from 'url'; import type { ViteDevServer } from 'vite'; import type { - AstroRenderer, AstroSettings, ComponentInstance, RouteData, @@ -9,16 +8,20 @@ import type { SSRElement, SSRLoadedRenderer, } from '../../../@types/astro'; +import type { DevelopmentEnvironment } from './environment'; import { PAGE_SCRIPT_ID } from '../../../vite-plugin-scripts/index.js'; import { LogOptions } from '../../logger/core.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 { collectMdMetadata } from '../util.js'; import { getStylesForURL } from './css.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 */ settings: AstroSettings; /** location of file on disk */ @@ -41,72 +44,81 @@ export interface SSROptions { 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 { - 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( viteServer: ViteDevServer, settings: AstroSettings ): Promise { - 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({ - settings, + env, filePath, - viteServer, -}: Pick): Promise { +}: Pick): Promise { // 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. - const mod = (await viteServer.ssrLoadModule(fileURLToPath(filePath))) as ComponentInstance; - if (viteServer.config.mode === 'development' || !mod?.$$metadata) { + const mod = (await env.viteServer.ssrLoadModule(fileURLToPath(filePath))) as ComponentInstance; + if (env.viteServer.config.mode === 'development' || !mod?.$$metadata) { return [renderers, mod]; } // 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) { - await collectMdMetadata(mod.$$metadata, modGraph, viteServer); + await collectMdMetadata(mod.$$metadata, modGraph, env.viteServer); } return [renderers, mod]; } -/** use Vite to SSR */ -export async function render( - renderers: SSRLoadedRenderer[], - mod: ComponentInstance, - ssrOpts: SSROptions -): Promise { - const { - settings, - filePath, - logging, - mode, - origin, - pathname, - request, - route, - routeCache, - viteServer, - } = ssrOpts; +interface GetScriptsAndStylesParams { + env: DevelopmentEnvironment; + filePath: URL; +} + +async function getScriptsAndStyles({ env, filePath }: GetScriptsAndStylesParams) { // Add hoisted script tags - const scripts = await getScriptsForURL(filePath, viteServer); + const scripts = await getScriptsForURL(filePath, env.viteServer); // Inject HMR scripts - if (isPage(filePath, settings) && mode === 'development') { + if (isPage(filePath, env.settings) && env.mode === 'development') { scripts.add({ props: { type: 'module', src: '/@vite/client' }, children: '', @@ -114,20 +126,20 @@ export async function render( scripts.add({ props: { type: 'module', - src: await resolveIdToUrl(viteServer, 'astro/runtime/client/hmr.js'), + src: await resolveIdToUrl(env.viteServer, 'astro/runtime/client/hmr.js'), }, children: '', }); } // 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') { scripts.add({ props: {}, children: script.content, }); - } else if (script.stage === 'page' && isPage(filePath, settings)) { + } else if (script.stage === 'page' && isPage(filePath, env.settings)) { scripts.add({ props: { type: 'module', src: `/@id/${PAGE_SCRIPT_ID}` }, children: '', @@ -136,7 +148,7 @@ export async function render( } // 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(); [...styleUrls].forEach((href) => { links.add({ @@ -164,54 +176,31 @@ export async function render( children: content, }); }); + + return { scripts, styles, links }; +} - let response = await coreRender({ - adapterName: settings.config.adapter?.name, - links, - styles, - logging, - markdown: { - ...settings.config.markdown, - isAstroFlavoredMd: settings.config.legacy.astroFlavoredMarkdown, - }, - mod, - 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, +export async function renderPage(options: SSROptions): Promise { + const [renderers, mod] = options.preload; + + // Override the environment's renderers. This ensures that if renderers change (HMR) + // The new instances are passed through. + options.env.renderers = renderers; + + const { scripts, links, styles } = await getScriptsAndStyles({ + env: options.env, + filePath: options.filePath }); - return response; -} + const ctx = createRenderContext({ + request: options.request, + origin: options.origin, + pathname: options.pathname, + scripts, + links, + styles, + route: options.route + }); -export async function ssr( - preloadedComponent: ComponentPreload, - ssrOpts: SSROptions -): Promise { - const [renderers, mod] = preloadedComponent; - return await render(renderers, mod, ssrOpts); // NOTE: without "await", errors won’t get caught below + return await coreRenderPage(mod, ctx, options.env); // NOTE: without "await", errors won’t get caught below } diff --git a/packages/astro/src/core/render/dev/resolve.ts b/packages/astro/src/core/render/dev/resolve.ts new file mode 100644 index 000000000..c4fc4e6b3 --- /dev/null +++ b/packages/astro/src/core/render/dev/resolve.ts @@ -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; + } + }; +} diff --git a/packages/astro/src/core/render/environment.ts b/packages/astro/src/core/render/environment.ts new file mode 100644 index 000000000..0afad9517 --- /dev/null +++ b/packages/astro/src/core/render/environment.ts @@ -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; + routeCache: RouteCache; + site?: string; + ssr: boolean; + streaming: boolean; +} + +export type CreateEnvironmentArgs = Environment; + +export function createEnvironment(options: CreateEnvironmentArgs): Environment { + return options; +} + +export type CreateBasicEnvironmentArgs = Partial & { + 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 + }); +} + diff --git a/packages/astro/src/core/render/index.ts b/packages/astro/src/core/render/index.ts new file mode 100644 index 000000000..4c543b598 --- /dev/null +++ b/packages/astro/src/core/render/index.ts @@ -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'; diff --git a/packages/astro/src/core/render/renderer.ts b/packages/astro/src/core/render/renderer.ts new file mode 100644 index 000000000..e82296a2a --- /dev/null +++ b/packages/astro/src/core/render/renderer.ts @@ -0,0 +1,28 @@ +import type { AstroRenderer, SSRLoadedRenderer } from '../../@types/astro'; + +export type RendererServerEntrypointModule = { + default: SSRLoadedRenderer['ssr']; +}; +export type MaybeRendererServerEntrypointModule = Partial; +export type RendererLoader = (entryPoint: string) => Promise; + +export async function loadRenderer(renderer: AstroRenderer, loader: RendererLoader): Promise { + 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[] { + return renderers.filter((renderer): renderer is SSRLoadedRenderer => { + return !!renderer; + }); +} + +export function createLoadedRenderer(renderer: AstroRenderer, mod: RendererServerEntrypointModule): SSRLoadedRenderer { + return { + ...renderer, + ssr: mod.default + }; +} diff --git a/packages/astro/src/core/render/result.ts b/packages/astro/src/core/render/result.ts index a87ea54c7..2d7c07d84 100644 --- a/packages/astro/src/core/render/result.ts +++ b/packages/astro/src/core/render/result.ts @@ -129,7 +129,7 @@ class Slots { let renderMarkdown: any = null; 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 headers = new Headers(); diff --git a/packages/astro/src/jsx/component.ts b/packages/astro/src/jsx/component.ts new file mode 100644 index 000000000..2c818334d --- /dev/null +++ b/packages/astro/src/jsx/component.ts @@ -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; +} diff --git a/packages/astro/src/jsx/index.ts b/packages/astro/src/jsx/index.ts new file mode 100644 index 000000000..00be71026 --- /dev/null +++ b/packages/astro/src/jsx/index.ts @@ -0,0 +1,6 @@ +export { + default as renderer +} from './renderer.js'; +export { + createAstroJSXComponent +} from './component.js'; diff --git a/packages/astro/src/runtime/server/index.ts b/packages/astro/src/runtime/server/index.ts index 28ec19a92..065fbea0a 100644 --- a/packages/astro/src/runtime/server/index.ts +++ b/packages/astro/src/runtime/server/index.ts @@ -21,6 +21,7 @@ export { stringifyChunk, voidElementNames, } from './render/index.js'; +export { renderJSX } from './jsx.js'; export type { AstroComponentFactory, RenderInstruction } from './render/index.js'; import type { AstroComponentFactory } from './render/index.js'; diff --git a/packages/astro/src/vite-plugin-astro-server/index.ts b/packages/astro/src/vite-plugin-astro-server/index.ts index deaade4b5..54bd42909 100644 --- a/packages/astro/src/vite-plugin-astro-server/index.ts +++ b/packages/astro/src/vite-plugin-astro-server/index.ts @@ -2,7 +2,7 @@ import type http from 'http'; import mime from 'mime'; import type * as vite from 'vite'; 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 { getSetCookiesFromResponse } from '../core/cookies/index.js'; @@ -16,9 +16,8 @@ import { import { error, info, LogOptions, warn } from '../core/logger/core.js'; import * as msg from '../core/messages.js'; import { appendForwardSlash } from '../core/path.js'; -import { getParamsAndProps, GetParamsAndPropsError } from '../core/render/core.js'; -import { preload, ssr } from '../core/render/dev/index.js'; -import { RouteCache } from '../core/render/route-cache.js'; +import { getParamsAndProps, GetParamsAndPropsError } from '../core/render/index.js'; +import { createDevelopmentEnvironment, preload, renderPage } from '../core/render/dev/index.js'; import { createRequest } from '../core/request.js'; import { createRouteManifest, matchAllRoutes } from '../core/routing/index.js'; import { resolvePages } from '../core/util.js'; @@ -100,7 +99,6 @@ async function writeSSRResult(webResponse: Response, res: http.ServerResponse) { async function handle404Response( origin: string, - settings: AstroSettings, req: http.IncomingMessage, res: http.ServerResponse ) { @@ -187,17 +185,15 @@ export function baseMiddleware( async function matchRoute( pathname: string, - routeCache: RouteCache, - viteServer: vite.ViteDevServer, - logging: LogOptions, + env: DevelopmentEnvironment, manifest: ManifestData, - settings: AstroSettings ) { + const { logging, settings, routeCache } = env; const matches = matchAllRoutes(pathname, manifest); for await (const maybeRoute of matches) { 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; // attempt to get static paths // if this fails, we have a bad URL match! @@ -233,7 +229,7 @@ async function matchRoute( if (custom404) { 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; return { @@ -249,14 +245,12 @@ async function matchRoute( /** The main logic to route dev server requests to pages in Astro. */ async function handleRequest( - routeCache: RouteCache, - viteServer: vite.ViteDevServer, - logging: LogOptions, + env: DevelopmentEnvironment, manifest: ManifestData, - settings: AstroSettings, req: http.IncomingMessage, res: http.ServerResponse ) { + const { settings, viteServer } = env; const { config } = settings; const origin = `${viteServer.config.server.https ? 'https' : 'http'}://${req.headers.host}`; const buildingToSSR = config.output === 'server'; @@ -296,11 +290,8 @@ async function handleRequest( try { const matchedRoute = await matchRoute( pathname, - routeCache, - viteServer, - logging, + env, manifest, - settings ); filePath = matchedRoute?.filePath; @@ -310,18 +301,15 @@ async function handleRequest( pathname, body, origin, - routeCache, - viteServer, + env, manifest, - logging, - settings, req, res ); } catch (_err) { const err = fixViteErrorMessage(_err, viteServer, filePath); const errorWithMetadata = collectErrorMetadata(err); - error(logging, null, msg.formatErrorMessage(errorWithMetadata)); + error(env.logging, null, msg.formatErrorMessage(errorWithMetadata)); handle500Response(viteServer, origin, req, res, errorWithMetadata); } } @@ -332,16 +320,14 @@ async function handleRoute( pathname: string, body: ArrayBuffer | undefined, origin: string, - routeCache: RouteCache, - viteServer: vite.ViteDevServer, + env: DevelopmentEnvironment, manifest: ManifestData, - logging: LogOptions, - settings: AstroSettings, req: http.IncomingMessage, res: http.ServerResponse ): Promise { + const { logging, settings } = env; if (!matchedRoute) { - return handle404Response(origin, settings, req, res); + return handle404Response(origin, req, res); } const { config } = settings; @@ -365,23 +351,20 @@ async function handleRoute( const paramsAndPropsRes = await getParamsAndProps({ mod, route, - routeCache, + routeCache: env.routeCache, pathname: pathname, logging, ssr: config.output === 'server', }); const options: SSROptions = { - settings, - filePath, - logging, - mode: 'development', - origin, - pathname: pathname, - route, - routeCache, - viteServer, - request, + env, + filePath, + origin, + preload: preloadedComponent, + pathname, + request, + route }; // Route successfully matched! Render it. @@ -391,11 +374,8 @@ async function handleRoute( if (result.response.headers.get('X-Astro-Response') === 'Not-Found') { const fourOhFourRoute = await matchRoute( '/404', - routeCache, - viteServer, - logging, - manifest, - settings + env, + manifest ); return handleRoute( fourOhFourRoute, @@ -403,11 +383,8 @@ async function handleRoute( '/404', body, origin, - routeCache, - viteServer, + env, manifest, - logging, - settings, req, res ); @@ -427,7 +404,7 @@ async function handleRoute( res.end(result.body); } } else { - const result = await ssr(preloadedComponent, options); + const result = await renderPage(options); return await writeSSRResult(result, res); } } @@ -436,11 +413,12 @@ export default function createPlugin({ settings, logging }: AstroPluginOptions): return { name: 'astro:server', configureServer(viteServer) { - let routeCache = new RouteCache(logging, 'development'); + let env = createDevelopmentEnvironment(settings, logging, viteServer); let manifest: ManifestData = createRouteManifest({ settings }, logging); + /** rebuild the route cache + manifest, as needed. */ function rebuildManifest(needsManifestRebuild: boolean, file: string) { - routeCache.clearAll(); + env.routeCache.clearAll(); if (needsManifestRebuild) { manifest = createRouteManifest({ settings }, logging); } @@ -461,7 +439,7 @@ export default function createPlugin({ settings, logging }: AstroPluginOptions): if (!req.url || !req.method) { throw new Error('Incomplete request'); } - handleRequest(routeCache, viteServer, logging, manifest, settings, req, res); + handleRequest(env, manifest, req, res); }); }; }, diff --git a/packages/astro/test/units/render/jsx.test.js b/packages/astro/test/units/render/jsx.test.js new file mode 100644 index 000000000..f2c2ceead --- /dev/null +++ b/packages/astro/test/units/render/jsx.test.js @@ -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`
${renderSlot(result, slots['myslot'])}
`; + }); + + 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('

works

'); + }); + }); +});