From b45214959505bc3402dd096bbd0cd39518e9957a Mon Sep 17 00:00:00 2001 From: Emanuele Stoppa Date: Wed, 9 Aug 2023 11:36:41 +0100 Subject: [PATCH] poc --- packages/astro/src/@types/astro.ts | 2 +- packages/astro/src/core/app/index.ts | 61 ++---- packages/astro/src/core/cookies/index.ts | 2 +- packages/astro/src/core/cookies/response.ts | 2 +- packages/astro/src/core/endpoint/index.ts | 4 +- packages/astro/src/core/pipeline.ts | 176 ++++++++++++++++++ packages/astro/src/core/render/context.ts | 3 +- packages/astro/src/core/render/core.ts | 9 +- packages/astro/src/core/render/index.ts | 2 +- .../src/vite-plugin-astro-server/route.ts | 19 +- 10 files changed, 212 insertions(+), 68 deletions(-) create mode 100644 packages/astro/src/core/pipeline.ts diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts index 1e594332d..284efcbbd 100644 --- a/packages/astro/src/@types/astro.ts +++ b/packages/astro/src/@types/astro.ts @@ -1869,7 +1869,7 @@ export interface SSRLoadedRenderer extends AstroRenderer { export type HookParameters< Hook extends keyof AstroIntegration['hooks'], - Fn = AstroIntegration['hooks'][Hook] + Fn = AstroIntegration['hooks'][Hook], > = Fn extends (...args: any) => any ? Parameters[0] : never; export interface AstroIntegration { diff --git a/packages/astro/src/core/app/index.ts b/packages/astro/src/core/app/index.ts index bec5368b6..78970c5dd 100644 --- a/packages/astro/src/core/app/index.ts +++ b/packages/astro/src/core/app/index.ts @@ -1,13 +1,13 @@ -import mime from 'mime'; import type { EndpointHandler, ManifestData, + MiddlewareEndpointHandler, RouteData, SSRElement, SSRManifest, } from '../../@types/astro'; import type { SinglePageBuiltModule } from '../build/types'; -import { attachToResponse, getSetCookiesFromResponse } from '../cookies/index.js'; +import { getSetCookiesFromResponse } from '../cookies/index.js'; import { consoleLogDestination } from '../logger/console.js'; import { error, type LogOptions } from '../logger/core.js'; import { @@ -16,12 +16,10 @@ import { removeTrailingForwardSlash, } from '../path.js'; import { RedirectSinglePageBuiltModule } from '../redirects/index.js'; -import { isResponse } from '../render/core.js'; import { createEnvironment, createRenderContext, tryRenderRoute, - type Environment, type RenderContext, } from '../render/index.js'; import { RouteCache } from '../render/route-cache.js'; @@ -32,6 +30,7 @@ import { } from '../render/ssr-element.js'; import { matchRoute } from '../routing/match.js'; import type { RouteInfo } from './types'; +import { SSRRoutePipeline } from '../pipeline'; export { deserializeManifest } from './common.js'; const clientLocalsSymbol = Symbol.for('astro.locals'); @@ -53,16 +52,15 @@ export class App { /** * The current environment of the application */ - #env: Environment; #manifest: SSRManifest; #manifestData: ManifestData; #routeDataToRouteInfo: Map; - #encoder = new TextEncoder(); #logging: LogOptions = { dest: consoleLogDestination, level: 'info', }; #baseWithoutTrailingSlash: string; + #pipeline: SSRRoutePipeline; constructor(manifest: SSRManifest, streaming = true) { this.#manifest = manifest; @@ -71,7 +69,7 @@ export class App { }; this.#routeDataToRouteInfo = new Map(manifest.routes.map((route) => [route.routeData, route])); this.#baseWithoutTrailingSlash = removeTrailingForwardSlash(this.#manifest.base); - this.#env = this.#createEnvironment(streaming); + this.#pipeline = new SSRRoutePipeline(this.#createEnvironment(streaming)); } set setManifest(newManifest: SSRManifest) { @@ -164,19 +162,16 @@ export class App { ); let response; try { - response = await tryRenderRoute( - routeData.type, - renderContext, - this.#env, - pageModule, - mod.onRequest - ); + if (mod.onRequest) { + this.#pipeline.setMiddlewareFunction(mod.onRequest as MiddlewareEndpointHandler); + } + response = await this.#pipeline.renderRoute(renderContext, pageModule); } catch (err: any) { error(this.#logging, 'ssr', err.stack || err.message || String(err)); return this.#renderError(request, { status: 500 }); } - if (isResponse(response, routeData.type)) { + if (SSRRoutePipeline.isResponse(response, routeData.type)) { if (STATUS_CODES.has(response.status)) { return this.#renderError(request, { response, @@ -185,35 +180,8 @@ export class App { } Reflect.set(response, responseSentSymbol, true); return response; - } else { - if (response.type === 'response') { - if (response.response.headers.get('X-Astro-Response') === 'Not-Found') { - return this.#renderError(request, { - response: response.response, - status: 404, - }); - } - return response.response; - } else { - const headers = new Headers(); - const mimeType = mime.getType(url.pathname); - if (mimeType) { - headers.set('Content-Type', `${mimeType};charset=utf-8`); - } else { - headers.set('Content-Type', 'text/plain;charset=utf-8'); - } - const bytes = - response.encoding !== 'binary' ? this.#encoder.encode(response.body) : response.body; - headers.set('Content-Length', bytes.byteLength.toString()); - - const newResponse = new Response(bytes, { - status: 200, - headers, - }); - attachToResponse(newResponse, response.cookies); - return newResponse; - } } + return response; } setCookieHeaders(response: Response) { @@ -239,7 +207,7 @@ export class App { pathname, route: routeData, status, - env: this.#env, + env: this.#pipeline.env, mod: handler as any, }); } else { @@ -273,7 +241,7 @@ export class App { route: routeData, status, mod, - env: this.#env, + env: this.#pipeline.env, }); } } @@ -302,9 +270,8 @@ export class App { ); const page = (await mod.page()) as any; const response = (await tryRenderRoute( - 'page', // this is hardcoded to ensure proper behavior for missing endpoints newRenderContext, - this.#env, + this.#pipeline.env, page )) as Response; return this.#mergeResponses(response, originalResponse); diff --git a/packages/astro/src/core/cookies/index.ts b/packages/astro/src/core/cookies/index.ts index 1b0c6b7a0..f3c7b6d61 100644 --- a/packages/astro/src/core/cookies/index.ts +++ b/packages/astro/src/core/cookies/index.ts @@ -1,2 +1,2 @@ export { AstroCookies } from './cookies.js'; -export { attachToResponse, getSetCookiesFromResponse } from './response.js'; +export { attachCookiesToResponse, getSetCookiesFromResponse } from './response.js'; diff --git a/packages/astro/src/core/cookies/response.ts b/packages/astro/src/core/cookies/response.ts index 18d72ab1c..668bd265f 100644 --- a/packages/astro/src/core/cookies/response.ts +++ b/packages/astro/src/core/cookies/response.ts @@ -2,7 +2,7 @@ import type { AstroCookies } from './cookies'; const astroCookiesSymbol = Symbol.for('astro.cookies'); -export function attachToResponse(response: Response, cookies: AstroCookies) { +export function attachCookiesToResponse(response: Response, cookies: AstroCookies) { Reflect.set(response, astroCookiesSymbol, cookies); } diff --git a/packages/astro/src/core/endpoint/index.ts b/packages/astro/src/core/endpoint/index.ts index 485190e47..ab6cb65c4 100644 --- a/packages/astro/src/core/endpoint/index.ts +++ b/packages/astro/src/core/endpoint/index.ts @@ -10,7 +10,7 @@ import type { Environment, RenderContext } from '../render/index'; import { renderEndpoint } from '../../runtime/server/index.js'; import { ASTRO_VERSION } from '../constants.js'; -import { AstroCookies, attachToResponse } from '../cookies/index.js'; +import { AstroCookies, attachCookiesToResponse } from '../cookies/index.js'; import { AstroError, AstroErrorData } from '../errors/index.js'; import { warn } from '../logger/core.js'; import { callMiddleware } from '../middleware/callMiddleware.js'; @@ -125,7 +125,7 @@ export async function callEndpoint } if (response instanceof Response) { - attachToResponse(response, context.cookies); + attachCookiesToResponse(response, context.cookies); return { type: 'response', response, diff --git a/packages/astro/src/core/pipeline.ts b/packages/astro/src/core/pipeline.ts new file mode 100644 index 000000000..bb13e02a6 --- /dev/null +++ b/packages/astro/src/core/pipeline.ts @@ -0,0 +1,176 @@ +import type { Environment } from './render/environment'; +import {createRenderContext, type RenderContext, tryRenderRoute} from './render'; +import type { EndpointCallResult } from './endpoint'; +import type { ComponentInstance, MiddlewareEndpointHandler, RouteType } from '../@types/astro'; +import { attachCookiesToResponse } from './cookies'; +import { TextEncoder } from 'util'; +import mime from 'mime'; +import type {TransformResult} from "@astrojs/compiler"; +import {createBasicEnvironment} from "../../test/units/test-utils"; + +/** + * Questions: + * 1. Can we call `getStaticPaths` really early?? Ideally when we load the component. -> idea is to make type the result of + * `getStaticPaths`, so we can make serializable and stub it via JS (no need of compiler or make a module). + * 2. When rendering a route, what are the info that belong to that route that are not shared with other routes? I guess: + * - the Request + * - a component instance? + * - styles? + * - scripts? + * - links? + * 3. In `RenderContext` we have a route which is a `RouteData`. What's used for? and why it can be optional? + */ + +/** + * IDEAS: + * - what if `handleRequest` dev, instead of directly rendering the page, returns only the info needed to render a route? + * It would return only the `RenderContext`, because that's what needed for a route to render. + */ + +type EndpointHandler = ( + originalRequest: Request, + result: EndpointCallResult +) => Promise | Response; + +export class Pipeline { + env: Environment; + onRequest?: MiddlewareEndpointHandler; + endpointHandler?: EndpointHandler; + + constructor(env: Environment) { + this.env = env; + } + + setEndpointHandler(handler: EndpointHandler) { + this.endpointHandler = handler; + } + + setMiddlewareFunction(onRequest: MiddlewareEndpointHandler) { + this.onRequest = onRequest; + } + + async renderRoute( + renderContext: RenderContext, + componentInstance: ComponentInstance + ): Promise { + const result = await tryRenderRoute(renderContext, this.env, componentInstance, this.onRequest); + if (Pipeline.isEndpointResult(result, renderContext.route.type)) { + if (!this.endpointHandler) { + throw new Error('You must set the endpoint handler'); + } + return this.endpointHandler(renderContext.request, result); + } else { + return result; + } + } + + static isEndpointResult(result: any, routeType: RouteType): result is EndpointCallResult { + return !(result instanceof Response) && routeType === 'endpoint'; + } + + static isResponse(result: any, routeType: RouteType): result is Response { + return result instanceof Response && (routeType === 'page' || routeType === 'redirect'); + } +} + +class DevRoutePipeline extends Pipeline { + clearRouteCache() { + this.env.routeCache.clearAll(); + } +} + +class BuildRoutePipeline extends Pipeline { + +} + +class TestRoutePipeline extends Pipeline { + // NOTE: we can also store JSX renderers is we need? + constructor() { + super(createBasicEnvironment()); + } + + async renderAstroPage(contents: string) { + const compilationResult = await this.#compile(contents); + const renderContext = await this.#computeTestContext(compilationResult); + const componentInstance = await this.#computeComponentInstance(compilationResult); + const response = await super.renderRoute(renderContext, componentInstance); + return response; + } + + // TODO: compute `RenderContext` from compilation result, probably + async #computeTestContext(result: Readonly): Promise { + } + + // TODO: compute `ComponentInstance` from compilation result, probably + async #computeComponentInstance(result: Readonly): Promise { + + } + + async #compile(contents: string): Promise { + const compiler = await import("@astrojs/compiler"); + const result = await compiler.transform(contents); + return result; + } +} + +// Example of testing + + +async function middleware_should_work() { + const testPipeline = new TestRoutePipeline(); + const page = ` +--- +const title = Astro.locals.title; +--- +{title} + `; + testPipeline.setMiddlewareFunction((context, next) => { + context.locals = { + title: "Test" + } + return next(); + }) + const result = await testPipeline.renderAstroPage(page); + const text = await result.text(); + // assertion text contains "Test" +} + + +export class SSRRoutePipeline extends Pipeline { + encoder = new TextEncoder(); + + constructor(env: Environment) { + super(env); + this.setEndpointHandler(this.ssrEndpointHandler); + } + + async ssrEndpointHandler(request: Request, response: EndpointCallResult): Promise { + if (response.type === 'response') { + if (response.response.headers.get('X-Astro-Response') === 'Not-Found') { + // TODO: throw proper astro error to catch in the app/index.ts, and render a 404 instead + throw new Error(''); + } + return response.response; + } else { + const url = new URL(request.url); + const headers = new Headers(); + const mimeType = mime.getType(url.pathname); + if (mimeType) { + headers.set('Content-Type', `${mimeType};charset=utf-8`); + } else { + headers.set('Content-Type', 'text/plain;charset=utf-8'); + } + const bytes = + response.encoding !== 'binary' ? this.encoder.encode(response.body) : response.body; + headers.set('Content-Length', bytes.byteLength.toString()); + + const newResponse = new Response(bytes, { + status: 200, + headers, + }); + attachCookiesToResponse(newResponse, response.cookies); + return newResponse; + } + } +} + diff --git a/packages/astro/src/core/render/context.ts b/packages/astro/src/core/render/context.ts index 5b26eda18..d767d7910 100644 --- a/packages/astro/src/core/render/context.ts +++ b/packages/astro/src/core/render/context.ts @@ -22,7 +22,7 @@ export interface RenderContext { links?: Set; styles?: Set; componentMetadata?: SSRResult['componentMetadata']; - route?: RouteData; + route: RouteData; status?: number; params: Params; props: Props; @@ -32,6 +32,7 @@ export interface RenderContext { export type CreateRenderContextArgs = Partial< Omit > & { + route: RouteData; request: RenderContext['request']; mod: ComponentInstance; env: Environment; diff --git a/packages/astro/src/core/render/core.ts b/packages/astro/src/core/render/core.ts index d6228fbbe..53fed4e8f 100644 --- a/packages/astro/src/core/render/core.ts +++ b/packages/astro/src/core/render/core.ts @@ -7,7 +7,7 @@ import type { RouteType, } from '../../@types/astro'; import { renderPage as runtimeRenderPage } from '../../runtime/server/index.js'; -import { attachToResponse } from '../cookies/index.js'; +import { attachCookiesToResponse } from '../cookies/index.js'; import { callEndpoint, createAPIContext, type EndpointCallResult } from '../endpoint/index.js'; import { callMiddleware } from '../middleware/callMiddleware.js'; import { redirectRouteGenerate, redirectRouteStatus, routeIsRedirect } from '../redirects/index.js'; @@ -76,7 +76,7 @@ async function renderPage({ mod, renderContext, env, cookies }: RenderPage) { // If there is an Astro.cookies instance, attach it to the response so that // adapters can grab the Set-Cookie headers. if (result.cookies) { - attachToResponse(response, result.cookies); + attachCookiesToResponse(response, result.cookies); } return response; @@ -93,7 +93,6 @@ async function renderPage({ mod, renderContext, env, cookies }: RenderPage) { * It throws an error if the page can't be rendered. */ export async function tryRenderRoute( - routeType: RouteType, renderContext: Readonly, env: Readonly, mod: Readonly, @@ -107,7 +106,7 @@ export async function tryRenderRoute( adapterName: env.adapterName, }); - switch (routeType) { + switch (renderContext.route.type) { case 'page': case 'redirect': { if (onRequest) { @@ -143,7 +142,7 @@ export async function tryRenderRoute( return result; } default: - throw new Error(`Couldn't find route of type [${routeType}]`); + throw new Error(`Couldn't find route of type [${renderContext.route.type}]`); } } diff --git a/packages/astro/src/core/render/index.ts b/packages/astro/src/core/render/index.ts index a82c5699e..20b964fa7 100644 --- a/packages/astro/src/core/render/index.ts +++ b/packages/astro/src/core/render/index.ts @@ -22,7 +22,7 @@ export interface SSROptions { /** Request */ request: Request; /** optional, in case we need to render something outside of a dev server */ - route?: RouteData; + route: RouteData; /** * Optional middlewares */ diff --git a/packages/astro/src/vite-plugin-astro-server/route.ts b/packages/astro/src/vite-plugin-astro-server/route.ts index f58d248a3..73d470dc5 100644 --- a/packages/astro/src/vite-plugin-astro-server/route.ts +++ b/packages/astro/src/vite-plugin-astro-server/route.ts @@ -5,10 +5,11 @@ import type { ManifestData, MiddlewareResponseHandler, RouteData, + RouteType, SSRElement, SSRManifest, } from '../@types/astro'; -import { attachToResponse } from '../core/cookies/index.js'; +import { attachCookiesToResponse } from '../core/cookies/index.js'; import { AstroErrorData, isAstroError } from '../core/errors/index.js'; import { warn } from '../core/logger/core.js'; import { loadMiddleware } from '../core/middleware/loadMiddleware.js'; @@ -49,18 +50,18 @@ export interface MatchedRoute { mod: ComponentInstance; } -function getCustom404Route(manifest: ManifestData): RouteData | undefined { +function getCustom404Route(manifestData: ManifestData): RouteData | undefined { const route404 = /^\/404\/?$/; - return manifest.routes.find((r) => route404.test(r.route)); + return manifestData.routes.find((r) => route404.test(r.route)); } export async function matchRoute( pathname: string, env: DevelopmentEnvironment, - manifest: ManifestData + manifestData: ManifestData ): Promise { const { logging, settings, routeCache } = env; - const matches = matchAllRoutes(pathname, manifest); + const matches = matchAllRoutes(pathname, manifestData); const preloadedMatches = await getSortedPreloadedMatches({ env, matches, settings }); for await (const { preloadedComponent, route: maybeRoute, filePath } of preloadedMatches) { @@ -96,7 +97,7 @@ export async function matchRoute( // build formats, and is necessary based on how the manifest tracks build targets. const altPathname = pathname.replace(/(index)?\.html$/, ''); if (altPathname !== pathname) { - return await matchRoute(altPathname, env, manifest); + return await matchRoute(altPathname, env, manifestData); } if (matches.length) { @@ -112,7 +113,7 @@ export async function matchRoute( } log404(logging, pathname); - const custom404 = getCustom404Route(manifest); + const custom404 = getCustom404Route(manifestData); if (custom404) { const filePath = new URL(`./${custom404.component}`, settings.config.root); @@ -216,7 +217,7 @@ export async function handleRoute({ }); const onRequest = options.middleware?.onRequest as MiddlewareResponseHandler | undefined; - const result = await tryRenderRoute(route.type, renderContext, env, mod, onRequest); + const result = await tryRenderRoute(renderContext, env, mod, onRequest); if (isEndpointResult(result, route.type)) { if (result.type === 'response') { if (result.response.headers.get('X-Astro-Response') === 'Not-Found') { @@ -255,7 +256,7 @@ export async function handleRoute({ }, } ); - attachToResponse(response, result.cookies); + attachCookiesToResponse(response, result.cookies); await writeWebResponse(incomingResponse, response); } } else {