This commit is contained in:
Emanuele Stoppa 2023-08-09 11:36:41 +01:00
parent 16161afb2b
commit b452149595
10 changed files with 212 additions and 68 deletions

View file

@ -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<Fn>[0] : never;
export interface AstroIntegration {

View file

@ -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<RouteData, RouteInfo>;
#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);

View file

@ -1,2 +1,2 @@
export { AstroCookies } from './cookies.js';
export { attachToResponse, getSetCookiesFromResponse } from './response.js';
export { attachCookiesToResponse, getSetCookiesFromResponse } from './response.js';

View file

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

View file

@ -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<MiddlewareResult = Response | EndpointOutput>
}
if (response instanceof Response) {
attachToResponse(response, context.cookies);
attachCookiesToResponse(response, context.cookies);
return {
type: 'response',
response,

View file

@ -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> | 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<Response> {
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<TransformResult>): Promise<RenderContext> {
}
// TODO: compute `ComponentInstance` from compilation result, probably
async #computeComponentInstance(result: Readonly<TransformResult>): Promise<ComponentInstance> {
}
async #compile(contents: string): Promise<TransformResult> {
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>{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<Response> {
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;
}
}
}

View file

@ -22,7 +22,7 @@ export interface RenderContext {
links?: Set<SSRElement>;
styles?: Set<SSRElement>;
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<RenderContext, 'params' | 'props' | 'locals'>
> & {
route: RouteData;
request: RenderContext['request'];
mod: ComponentInstance;
env: Environment;

View file

@ -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<MiddlewareReturnType = Response>(
routeType: RouteType,
renderContext: Readonly<RenderContext>,
env: Readonly<Environment>,
mod: Readonly<ComponentInstance>,
@ -107,7 +106,7 @@ export async function tryRenderRoute<MiddlewareReturnType = Response>(
adapterName: env.adapterName,
});
switch (routeType) {
switch (renderContext.route.type) {
case 'page':
case 'redirect': {
if (onRequest) {
@ -143,7 +142,7 @@ export async function tryRenderRoute<MiddlewareReturnType = Response>(
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}]`);
}
}

View file

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

View file

@ -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<MatchedRoute | undefined> {
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 {