UNPKG

astro

Version:

Astro is a modern site builder with web best practices, performance, and DX front-of-mind.

588 lines (587 loc) • 21.2 kB
import { green } from "kleur/colors"; import { getActionContext } from "../actions/runtime/virtual/server.js"; import { deserializeActionResult } from "../actions/runtime/virtual/shared.js"; import { createCallAction, createGetActionResult, hasActionPayload } from "../actions/utils.js"; import { computeCurrentLocale, computePreferredLocale, computePreferredLocaleList } from "../i18n/utils.js"; import { renderEndpoint } from "../runtime/server/endpoint.js"; import { renderPage } from "../runtime/server/index.js"; import { ASTRO_VERSION, REROUTE_DIRECTIVE_HEADER, REWRITE_DIRECTIVE_HEADER_KEY, REWRITE_DIRECTIVE_HEADER_VALUE, ROUTE_TYPE_HEADER, clientAddressSymbol, responseSentSymbol } from "./constants.js"; import { AstroCookies, attachCookiesToResponse } from "./cookies/index.js"; import { getCookiesFromResponse } from "./cookies/response.js"; import { ForbiddenRewrite } from "./errors/errors-data.js"; import { AstroError, AstroErrorData } from "./errors/index.js"; import { callMiddleware } from "./middleware/callMiddleware.js"; import { sequence } from "./middleware/index.js"; import { renderRedirect } from "./redirects/render.js"; import { Slots, getParams, getProps } from "./render/index.js"; import { isRoute404or500, isRouteExternalRedirect, isRouteServerIsland } from "./routing/match.js"; import { copyRequest, getOriginPathname, setOriginPathname } from "./routing/rewrite.js"; import { AstroSession } from "./session.js"; const apiContextRoutesSymbol = Symbol.for("context.routes"); class RenderContext { constructor(pipeline, locals, middleware, actions, pathname, request, routeData, status, clientAddress, cookies = new AstroCookies(request), params = getParams(routeData, pathname), url = new URL(request.url), props = {}, partial = void 0, session = pipeline.manifest.sessionConfig ? new AstroSession(cookies, pipeline.manifest.sessionConfig, pipeline.runtimeMode) : void 0) { this.pipeline = pipeline; this.locals = locals; this.middleware = middleware; this.actions = actions; this.pathname = pathname; this.request = request; this.routeData = routeData; this.status = status; this.clientAddress = clientAddress; this.cookies = cookies; this.params = params; this.url = url; this.props = props; this.partial = partial; this.session = session; } /** * A flag that tells the render content if the rewriting was triggered */ isRewriting = false; /** * A safety net in case of loops */ counter = 0; static async create({ locals = {}, middleware, pathname, pipeline, request, routeData, clientAddress, status = 200, props, partial = void 0, actions }) { const pipelineMiddleware = await pipeline.getMiddleware(); const pipelineActions = actions ?? await pipeline.getActions(); setOriginPathname(request, pathname); return new RenderContext( pipeline, locals, sequence(...pipeline.internalMiddleware, middleware ?? pipelineMiddleware), pipelineActions, pathname, request, routeData, status, clientAddress, void 0, void 0, void 0, props, partial ); } /** * The main function of the RenderContext. * * Use this function to render any route known to Astro. * It attempts to render a route. A route can be a: * * - page * - redirect * - endpoint * - fallback */ async render(componentInstance, slots = {}) { const { cookies, middleware, pipeline } = this; const { logger, serverLike, streaming, manifest } = pipeline; const props = Object.keys(this.props).length > 0 ? this.props : await getProps({ mod: componentInstance, routeData: this.routeData, routeCache: this.pipeline.routeCache, pathname: this.pathname, logger, serverLike, base: manifest.base }); const actionApiContext = this.createActionAPIContext(); const apiContext = this.createAPIContext(props, actionApiContext); this.counter++; if (this.counter === 4) { return new Response("Loop Detected", { // https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/508 status: 508, statusText: "Astro detected a loop where you tried to call the rewriting logic more than four times." }); } const lastNext = async (ctx, payload) => { if (payload) { const oldPathname = this.pathname; pipeline.logger.debug("router", "Called rewriting to:", payload); const { routeData, componentInstance: newComponent, pathname, newUrl } = await pipeline.tryRewrite(payload, this.request); if (this.pipeline.serverLike === true && this.routeData.prerender === false && routeData.prerender === true) { throw new AstroError({ ...ForbiddenRewrite, message: ForbiddenRewrite.message(this.pathname, pathname, routeData.component), hint: ForbiddenRewrite.hint(routeData.component) }); } this.routeData = routeData; componentInstance = newComponent; if (payload instanceof Request) { this.request = payload; } else { this.request = copyRequest( newUrl, this.request, // need to send the flag of the previous routeData routeData.prerender, this.pipeline.logger, this.routeData.route ); } this.isRewriting = true; this.url = new URL(this.request.url); this.cookies = new AstroCookies(this.request); this.params = getParams(routeData, pathname); this.pathname = pathname; this.status = 200; setOriginPathname(this.request, oldPathname); } let response2; if (!ctx.isPrerendered) { const { action, setActionResult, serializeActionResult } = getActionContext(ctx); if (action?.calledFrom === "form") { const actionResult = await action.handler(); setActionResult(action.name, serializeActionResult(actionResult)); } } switch (this.routeData.type) { case "endpoint": { response2 = await renderEndpoint( componentInstance, ctx, this.routeData.prerender, logger ); break; } case "redirect": return renderRedirect(this); case "page": { const result = await this.createResult(componentInstance, actionApiContext); try { response2 = await renderPage( result, componentInstance?.default, props, slots, streaming, this.routeData ); } catch (e) { result.cancelled = true; throw e; } response2.headers.set(ROUTE_TYPE_HEADER, "page"); if (this.routeData.route === "/404" || this.routeData.route === "/500") { response2.headers.set(REROUTE_DIRECTIVE_HEADER, "no"); } if (this.isRewriting) { response2.headers.set(REWRITE_DIRECTIVE_HEADER_KEY, REWRITE_DIRECTIVE_HEADER_VALUE); } break; } case "fallback": { return new Response(null, { status: 500, headers: { [ROUTE_TYPE_HEADER]: "fallback" } }); } } const responseCookies = getCookiesFromResponse(response2); if (responseCookies) { cookies.merge(responseCookies); } return response2; }; if (isRouteExternalRedirect(this.routeData)) { return renderRedirect(this); } const response = await callMiddleware(middleware, apiContext, lastNext); if (response.headers.get(ROUTE_TYPE_HEADER)) { response.headers.delete(ROUTE_TYPE_HEADER); } attachCookiesToResponse(response, cookies); return response; } createAPIContext(props, context) { const redirect = (path, status = 302) => new Response(null, { status, headers: { Location: path } }); Reflect.set(context, apiContextRoutesSymbol, this.pipeline); return Object.assign(context, { props, redirect, getActionResult: createGetActionResult(context.locals), callAction: createCallAction(context) }); } async #executeRewrite(reroutePayload) { this.pipeline.logger.debug("router", "Calling rewrite: ", reroutePayload); const oldPathname = this.pathname; const { routeData, componentInstance, newUrl, pathname } = await this.pipeline.tryRewrite( reroutePayload, this.request ); if (this.pipeline.serverLike && !this.routeData.prerender && routeData.prerender) { throw new AstroError({ ...ForbiddenRewrite, message: ForbiddenRewrite.message(this.pathname, pathname, routeData.component), hint: ForbiddenRewrite.hint(routeData.component) }); } this.routeData = routeData; if (reroutePayload instanceof Request) { this.request = reroutePayload; } else { this.request = copyRequest( newUrl, this.request, // need to send the flag of the previous routeData routeData.prerender, this.pipeline.logger, this.routeData.route ); } this.url = new URL(this.request.url); this.cookies = new AstroCookies(this.request); this.params = getParams(routeData, pathname); this.pathname = pathname; this.isRewriting = true; this.status = 200; setOriginPathname(this.request, oldPathname); return await this.render(componentInstance); } createActionAPIContext() { const renderContext = this; const { cookies, params, pipeline, url } = this; const generator = `Astro v${ASTRO_VERSION}`; const rewrite = async (reroutePayload) => { return await this.#executeRewrite(reroutePayload); }; return { cookies, routePattern: this.routeData.route, isPrerendered: this.routeData.prerender, get clientAddress() { return renderContext.getClientAddress(); }, get currentLocale() { return renderContext.computeCurrentLocale(); }, generator, get locals() { return renderContext.locals; }, set locals(_) { throw new AstroError(AstroErrorData.LocalsReassigned); }, params, get preferredLocale() { return renderContext.computePreferredLocale(); }, get preferredLocaleList() { return renderContext.computePreferredLocaleList(); }, rewrite, request: this.request, site: pipeline.site, url, get originPathname() { return getOriginPathname(renderContext.request); }, get session() { if (this.isPrerendered) { pipeline.logger.warn( "session", `context.session was used when rendering the route ${green(this.routePattern)}, but it is not available on prerendered routes. If you need access to sessions, make sure that the route is server-rendered using \`export const prerender = false;\` or by setting \`output\` to \`"server"\` in your Astro config to make all your routes server-rendered by default. For more information, see https://docs.astro.build/en/guides/sessions/` ); return void 0; } if (!renderContext.session) { pipeline.logger.warn( "session", `context.session was used when rendering the route ${green(this.routePattern)}, but no storage configuration was provided. Either configure the storage manually or use an adapter that provides session storage. For more information, see https://docs.astro.build/en/guides/sessions/` ); return void 0; } return renderContext.session; } }; } async createResult(mod, ctx) { const { cookies, pathname, pipeline, routeData, status } = this; const { clientDirectives, inlinedScripts, compressHTML, manifest, renderers, resolve } = pipeline; const { links, scripts, styles } = await pipeline.headElements(routeData); const componentMetadata = await pipeline.componentMetadata(routeData) ?? manifest.componentMetadata; const headers = new Headers({ "Content-Type": "text/html" }); const partial = typeof this.partial === "boolean" ? this.partial : Boolean(mod.partial); const actionResult = hasActionPayload(this.locals) ? deserializeActionResult(this.locals._actionPayload.actionResult) : void 0; const response = { status: actionResult?.error ? actionResult?.error.status : status, statusText: actionResult?.error ? actionResult?.error.type : "OK", get headers() { return headers; }, // Disallow `Astro.response.headers = new Headers` set headers(_) { throw new AstroError(AstroErrorData.AstroResponseHeadersReassigned); } }; const result = { base: manifest.base, userAssetsBase: manifest.userAssetsBase, cancelled: false, clientDirectives, inlinedScripts, componentMetadata, compressHTML, cookies, /** This function returns the `Astro` faux-global */ createAstro: (astroGlobal, props, slots) => this.createAstro(result, astroGlobal, props, slots, ctx), links, params: this.params, partial, pathname, renderers, resolve, response, request: this.request, scripts, styles, actionResult, serverIslandNameMap: manifest.serverIslandNameMap ?? /* @__PURE__ */ new Map(), key: manifest.key, trailingSlash: manifest.trailingSlash, _metadata: { hasHydrationScript: false, rendererSpecificHydrationScripts: /* @__PURE__ */ new Set(), hasRenderedHead: false, renderedScripts: /* @__PURE__ */ new Set(), hasDirectives: /* @__PURE__ */ new Set(), hasRenderedServerIslandRuntime: false, headInTree: false, extraHead: [], propagators: /* @__PURE__ */ new Set() } }; return result; } #astroPagePartial; /** * The Astro global is sourced in 3 different phases: * - **Static**: `.generator` and `.glob` is printed by the compiler, instantiated once per process per astro file * - **Page-level**: `.request`, `.cookies`, `.locals` etc. These remain the same for the duration of the request. * - **Component-level**: `.props`, `.slots`, and `.self` are unique to each _use_ of each component. * * The page level partial is used as the prototype of the user-visible `Astro` global object, which is instantiated once per use of a component. */ createAstro(result, astroStaticPartial, props, slotValues, apiContext) { let astroPagePartial; if (this.isRewriting) { astroPagePartial = this.#astroPagePartial = this.createAstroPagePartial( result, astroStaticPartial, apiContext ); } else { astroPagePartial = this.#astroPagePartial ??= this.createAstroPagePartial( result, astroStaticPartial, apiContext ); } const astroComponentPartial = { props, self: null }; const Astro = Object.assign( Object.create(astroPagePartial), astroComponentPartial ); let _slots; Object.defineProperty(Astro, "slots", { get: () => { if (!_slots) { _slots = new Slots( result, slotValues, this.pipeline.logger ); } return _slots; } }); return Astro; } createAstroPagePartial(result, astroStaticPartial, apiContext) { const renderContext = this; const { cookies, locals, params, pipeline, url } = this; const { response } = result; const redirect = (path, status = 302) => { if (this.request[responseSentSymbol]) { throw new AstroError({ ...AstroErrorData.ResponseSentError }); } return new Response(null, { status, headers: { Location: path } }); }; const rewrite = async (reroutePayload) => { return await this.#executeRewrite(reroutePayload); }; const callAction = createCallAction(apiContext); return { generator: astroStaticPartial.generator, glob: astroStaticPartial.glob, routePattern: this.routeData.route, isPrerendered: this.routeData.prerender, cookies, get session() { if (this.isPrerendered) { pipeline.logger.warn( "session", `Astro.session was used when rendering the route ${green(this.routePattern)}, but it is not available on prerendered pages. If you need access to sessions, make sure that the page is server-rendered using \`export const prerender = false;\` or by setting \`output\` to \`"server"\` in your Astro config to make all your pages server-rendered by default. For more information, see https://docs.astro.build/en/guides/sessions/` ); return void 0; } if (!renderContext.session) { pipeline.logger.warn( "session", `Astro.session was used when rendering the route ${green(this.routePattern)}, but no storage configuration was provided. Either configure the storage manually or use an adapter that provides session storage. For more information, see https://docs.astro.build/en/guides/sessions/` ); return void 0; } return renderContext.session; }, get clientAddress() { return renderContext.getClientAddress(); }, get currentLocale() { return renderContext.computeCurrentLocale(); }, params, get preferredLocale() { return renderContext.computePreferredLocale(); }, get preferredLocaleList() { return renderContext.computePreferredLocaleList(); }, locals, redirect, rewrite, request: this.request, response, site: pipeline.site, getActionResult: createGetActionResult(locals), get callAction() { return callAction; }, url, get originPathname() { return getOriginPathname(renderContext.request); } }; } getClientAddress() { const { pipeline, request, routeData, clientAddress } = this; if (routeData.prerender) { throw new AstroError({ ...AstroErrorData.PrerenderClientAddressNotAvailable, message: AstroErrorData.PrerenderClientAddressNotAvailable.message(routeData.component) }); } if (clientAddress) { return clientAddress; } if (clientAddressSymbol in request) { return Reflect.get(request, clientAddressSymbol); } if (pipeline.adapterName) { throw new AstroError({ ...AstroErrorData.ClientAddressNotAvailable, message: AstroErrorData.ClientAddressNotAvailable.message(pipeline.adapterName) }); } throw new AstroError(AstroErrorData.StaticClientAddressNotAvailable); } /** * API Context may be created multiple times per request, i18n data needs to be computed only once. * So, it is computed and saved here on creation of the first APIContext and reused for later ones. */ #currentLocale; computeCurrentLocale() { const { url, pipeline: { i18n }, routeData } = this; if (!i18n) return; const { defaultLocale, locales, strategy } = i18n; const fallbackTo = strategy === "pathname-prefix-other-locales" || strategy === "domains-prefix-other-locales" ? defaultLocale : void 0; if (this.#currentLocale) { return this.#currentLocale; } let computedLocale; if (isRouteServerIsland(routeData)) { let referer = this.request.headers.get("referer"); if (referer) { if (URL.canParse(referer)) { referer = new URL(referer).pathname; } computedLocale = computeCurrentLocale(referer, locales, defaultLocale); } } else { let pathname = routeData.pathname; if (!routeData.pattern.test(url.pathname)) { for (const fallbackRoute of routeData.fallbackRoutes) { if (fallbackRoute.pattern.test(url.pathname)) { pathname = fallbackRoute.pathname; break; } } } pathname = pathname && !isRoute404or500(routeData) ? pathname : url.pathname; computedLocale = computeCurrentLocale(pathname, locales, defaultLocale); } this.#currentLocale = computedLocale ?? fallbackTo; return this.#currentLocale; } #preferredLocale; computePreferredLocale() { const { pipeline: { i18n }, request } = this; if (!i18n) return; return this.#preferredLocale ??= computePreferredLocale(request, i18n.locales); } #preferredLocaleList; computePreferredLocaleList() { const { pipeline: { i18n }, request } = this; if (!i18n) return; return this.#preferredLocaleList ??= computePreferredLocaleList(request, i18n.locales); } } export { RenderContext, apiContextRoutesSymbol };