diff --git a/.changeset/pink-ligers-share.md b/.changeset/pink-ligers-share.md new file mode 100644 index 000000000000..e7923350fb5b --- /dev/null +++ b/.changeset/pink-ligers-share.md @@ -0,0 +1,49 @@ +--- +"astro": minor +--- + +Adds experimental rewriting in Astro with a new `rewrite()` function and the middleware `next()` function. + +The feature is available via an experimental flag in `astro.config.mjs`: + +```js +export default defineConfig({ + experimental: { + rewriting: true + } +}) +``` + +When enabled, you can use `rewrite()` to **render** another page without changing the URL of the browser in Astro pages and endpoints. + +```astro +--- +// src/pages/dashboard.astro +if (!Astro.props.allowed) { + return Astro.rewrite("/") +} +--- +``` + +```js +// src/pages/api.js +export function GET(ctx) { + if (!ctx.locals.allowed) { + return ctx.rewrite("/") + } +} +``` + +The middleware `next()` function now accepts a parameter with the same type as the `rewrite()` function. For example, with `next("/")`, you can call the next middleware function with a new `Request`. + +```js +// src/middleware.js +export function onRequest(ctx, next) { + if (!ctx.cookies.get("allowed")) { + return next("/") // new signature + } + return next(); +} +``` + +> **NOTE**: please [read the RFC](https://github.com/withastro/roadmap/blob/feat/reroute/proposals/0047-rerouting.md) to understand the current expectations of the new APIs. diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts index 0cff203cf227..ba9cfe3a7d09 100644 --- a/packages/astro/src/@types/astro.ts +++ b/packages/astro/src/@types/astro.ts @@ -250,6 +250,19 @@ export interface AstroGlobal< * [Astro reference](https://docs.astro.build/en/guides/server-side-rendering/) */ redirect: AstroSharedContext['redirect']; + /** + * It rewrites to another page. As opposed to redirects, the URL won't change, and Astro will render the HTML emitted + * by the rewritten URL passed as argument. + * + * ## Example + * + * ```js + * if (pageIsNotEnabled) { + * return Astro.rewrite('/fallback-page') + * } + * ``` + */ + rewrite: AstroSharedContext['rewrite']; /** * The element allows a component to reference itself recursively. * @@ -1641,7 +1654,7 @@ export interface AstroUserConfig { domains?: Record; }; - /** ⚠️ WARNING: SUBJECT TO CHANGE */ + /** ! WARNING: SUBJECT TO CHANGE */ db?: Config.Database; /** @@ -1922,6 +1935,62 @@ export interface AstroUserConfig { origin?: boolean; }; }; + + /** + * @docs + * @name experimental.rewriting + * @type {boolean} + * @default `false` + * @version 4.8.0 + * @description + * + * Enables a routing feature for rewriting requests in Astro pages, endpoints and Astro middleware, giving you programmatic control over your routes. + * + * ```js + * { + * experimental: { + * rewriting: true, + * }, + * } + * ``` + * + * Use `Astro.rewrite` in your `.astro` files to reroute to a different page: + * + * ```astro "rewrite" + * --- + * // src/pages/dashboard.astro + * if (!Astro.props.allowed) { + * return Astro.rewrite("/") + * } + * --- + * ``` + * + * Use `context.rewrite` in your endpoint files to reroute to a different page: + * + * ```js + * // src/pages/api.js + * export function GET(ctx) { + * if (!ctx.locals.allowed) { + * return ctx.rewrite("/") + * } + * } + * ``` + * + * Use `next("/")` in your middleware file to reroute to a different page, and then call the next middleware function: + * + * ```js + * // src/middleware.js + * export function onRequest(ctx, next) { + * if (!ctx.cookies.get("allowed")) { + * return next("/") // new signature + * } + * return next(); + * } + * ``` + * + * For a complete overview, and to give feedback on this experimental API, see the [Rerouting RFC](https://github.com/withastro/roadmap/blob/feat/reroute/proposals/0047-rerouting.md). + */ + rewriting: boolean; }; } @@ -2491,6 +2560,20 @@ interface AstroSharedContext< */ redirect(path: string, status?: ValidRedirectStatus): Response; + /** + * It rewrites to another page. As opposed to redirects, the URL won't change, and Astro will render the HTML emitted + * by the rerouted URL passed as argument. + * + * ## Example + * + * ```js + * if (pageIsNotEnabled) { + * return Astro.rewrite('/fallback-page') + * } + * ``` + */ + rewrite(rewritePayload: RewritePayload): Promise; + /** * Object accessed via Astro middleware */ @@ -2605,6 +2688,21 @@ export interface APIContext< */ redirect: AstroSharedContext['redirect']; + /** + * It reroutes to another page. As opposed to redirects, the URL won't change, and Astro will render the HTML emitted + * by the rerouted URL passed as argument. + * + * ## Example + * + * ```ts + * // src/pages/secret.ts + * export function GET(ctx) { + * return ctx.rewrite(new URL("../"), ctx.url); + * } + * ``` + */ + rewrite: AstroSharedContext['rewrite']; + /** * An object that middlewares can use to store extra information related to the request. * @@ -2799,7 +2897,9 @@ export interface AstroIntegration { }; } -export type MiddlewareNext = () => Promise; +export type RewritePayload = string | URL | Request; + +export type MiddlewareNext = (rewritePayload?: RewritePayload) => Promise; export type MiddlewareHandler = ( context: APIContext, next: MiddlewareNext diff --git a/packages/astro/src/core/app/index.ts b/packages/astro/src/core/app/index.ts index 116151610e1c..1ba5d9479833 100644 --- a/packages/astro/src/core/app/index.ts +++ b/packages/astro/src/core/app/index.ts @@ -1,13 +1,6 @@ -import type { - ComponentInstance, - ManifestData, - RouteData, - SSRManifest, -} from '../../@types/astro.js'; +import type { ManifestData, RouteData, SSRManifest } from '../../@types/astro.js'; import { normalizeTheLocale } from '../../i18n/index.js'; -import type { SinglePageBuiltModule } from '../build/types.js'; import { - DEFAULT_404_COMPONENT, REROUTABLE_STATUS_CODES, REROUTE_DIRECTIVE_HEADER, clientAddressSymbol, @@ -26,7 +19,6 @@ import { prependForwardSlash, removeTrailingForwardSlash, } from '../path.js'; -import { RedirectSinglePageBuiltModule } from '../redirects/index.js'; import { RenderContext } from '../render-context.js'; import { createAssetLink } from '../render/ssr-element.js'; import { ensure404Route } from '../routing/astro-designed-error-pages.js'; @@ -96,7 +88,7 @@ export class App { routes: manifest.routes.map((route) => route.routeData), }); this.#baseWithoutTrailingSlash = removeTrailingForwardSlash(this.#manifest.base); - this.#pipeline = this.#createPipeline(streaming); + this.#pipeline = this.#createPipeline(this.#manifestData, streaming); this.#adapterLogger = new AstroIntegrationLogger( this.#logger.options, this.#manifest.adapterName @@ -110,10 +102,11 @@ export class App { /** * Creates a pipeline by reading the stored manifest * + * @param manifestData * @param streaming * @private */ - #createPipeline(streaming = false) { + #createPipeline(manifestData: ManifestData, streaming = false) { if (this.#manifest.checkOrigin) { this.#manifest.middleware = sequence( createOriginCheckMiddleware(), @@ -121,7 +114,7 @@ export class App { ); } - return AppPipeline.create({ + return AppPipeline.create(manifestData, { logger: this.#logger, manifest: this.#manifest, mode: 'production', @@ -309,7 +302,7 @@ export class App { } const pathname = this.#getPathnameFromRequest(request); const defaultStatus = this.#getDefaultStatusCode(routeData, pathname); - const mod = await this.#getModuleForRoute(routeData); + const mod = await this.#pipeline.getModuleForRoute(routeData); let response; try { @@ -405,7 +398,7 @@ export class App { return this.#mergeResponses(response, originalResponse, override); } - const mod = await this.#getModuleForRoute(errorRouteData); + const mod = await this.#pipeline.getModuleForRoute(errorRouteData); try { const renderContext = RenderContext.create({ locals, @@ -493,35 +486,4 @@ export class App { if (route.endsWith('/500')) return 500; return 200; } - - async #getModuleForRoute(route: RouteData): Promise { - if (route.component === DEFAULT_404_COMPONENT) { - return { - page: async () => - ({ default: () => new Response(null, { status: 404 }) }) as ComponentInstance, - renderers: [], - }; - } - if (route.type === 'redirect') { - return RedirectSinglePageBuiltModule; - } else { - if (this.#manifest.pageMap) { - const importComponentInstance = this.#manifest.pageMap.get(route.component); - if (!importComponentInstance) { - throw new Error( - `Unexpectedly unable to find a component instance for route ${route.route}` - ); - } - const pageModule = await importComponentInstance(); - return pageModule; - } else if (this.#manifest.pageModule) { - const importComponentInstance = this.#manifest.pageModule; - return importComponentInstance; - } else { - throw new Error( - "Astro couldn't find the correct page to render, probably because it wasn't correctly mapped for SSR usage. This is an internal error, please file an issue." - ); - } - } - } } diff --git a/packages/astro/src/core/app/pipeline.ts b/packages/astro/src/core/app/pipeline.ts index b1c615a1eb36..77d2f80b24f2 100644 --- a/packages/astro/src/core/app/pipeline.ts +++ b/packages/astro/src/core/app/pipeline.ts @@ -1,21 +1,46 @@ -import type { RouteData, SSRElement, SSRResult } from '../../@types/astro.js'; +import type { + ManifestData, + RouteData, + SSRElement, + SSRResult, + ComponentInstance, + RewritePayload, +} from '../../@types/astro.js'; import { Pipeline } from '../base-pipeline.js'; +import { DEFAULT_404_COMPONENT } from '../constants.js'; +import { RedirectSinglePageBuiltModule } from '../redirects/component.js'; import { createModuleScriptElement, createStylesheetElementSet } from '../render/ssr-element.js'; +import type { SinglePageBuiltModule } from '../build/types.js'; export class AppPipeline extends Pipeline { - static create({ - logger, - manifest, - mode, - renderers, - resolve, - serverLike, - streaming, - }: Pick< - AppPipeline, - 'logger' | 'manifest' | 'mode' | 'renderers' | 'resolve' | 'serverLike' | 'streaming' - >) { - return new AppPipeline(logger, manifest, mode, renderers, resolve, serverLike, streaming); + #manifestData: ManifestData | undefined; + + static create( + manifestData: ManifestData, + { + logger, + manifest, + mode, + renderers, + resolve, + serverLike, + streaming, + }: Pick< + AppPipeline, + 'logger' | 'manifest' | 'mode' | 'renderers' | 'resolve' | 'serverLike' | 'streaming' + > + ) { + const pipeline = new AppPipeline( + logger, + manifest, + mode, + renderers, + resolve, + serverLike, + streaming + ); + pipeline.#manifestData = manifestData; + return pipeline; } headElements(routeData: RouteData): Pick { @@ -41,4 +66,64 @@ export class AppPipeline extends Pipeline { } componentMetadata() {} + async getComponentByRoute(routeData: RouteData): Promise { + const module = await this.getModuleForRoute(routeData); + return module.page(); + } + + async tryRewrite(payload: RewritePayload): Promise<[RouteData, ComponentInstance]> { + let foundRoute; + + for (const route of this.#manifestData!.routes) { + if (payload instanceof URL) { + if (route.pattern.test(payload.pathname)) { + foundRoute = route; + break; + } + } else if (payload instanceof Request) { + const url = new URL(payload.url); + if (route.pattern.test(url.pathname)) { + foundRoute = route; + break; + } + } else if (route.pattern.test(decodeURI(payload))) { + foundRoute = route; + break; + } + } + + if (foundRoute) { + const componentInstance = await this.getComponentByRoute(foundRoute); + return [foundRoute, componentInstance]; + } + throw new Error('Route not found'); + } + + async getModuleForRoute(route: RouteData): Promise { + if (route.component === DEFAULT_404_COMPONENT) { + return { + page: async () => + ({ default: () => new Response(null, { status: 404 }) }) as ComponentInstance, + renderers: [], + }; + } + if (route.type === 'redirect') { + return RedirectSinglePageBuiltModule; + } else { + if (this.manifest.pageMap) { + const importComponentInstance = this.manifest.pageMap.get(route.component); + if (!importComponentInstance) { + throw new Error( + `Unexpectedly unable to find a component instance for route ${route.route}` + ); + } + return await importComponentInstance(); + } else if (this.manifest.pageModule) { + return this.manifest.pageModule; + } + throw new Error( + "Astro couldn't find the correct page to render, probably because it wasn't correctly mapped for SSR usage. This is an internal error, please file an issue." + ); + } + } } diff --git a/packages/astro/src/core/app/types.ts b/packages/astro/src/core/app/types.ts index fd56c6f1068f..30134252ef9d 100644 --- a/packages/astro/src/core/app/types.ts +++ b/packages/astro/src/core/app/types.ts @@ -65,6 +65,8 @@ export type SSRManifest = { i18n: SSRManifestI18n | undefined; middleware: MiddlewareHandler; checkOrigin: boolean; + // TODO: remove once the experimental flag is removed + rewritingEnabled: boolean; }; export type SSRManifestI18n = { diff --git a/packages/astro/src/core/base-pipeline.ts b/packages/astro/src/core/base-pipeline.ts index 832823db35fa..11cff7c809f5 100644 --- a/packages/astro/src/core/base-pipeline.ts +++ b/packages/astro/src/core/base-pipeline.ts @@ -1,5 +1,7 @@ import type { + ComponentInstance, MiddlewareHandler, + RewritePayload, RouteData, RuntimeMode, SSRLoadedRenderer, @@ -59,6 +61,23 @@ export abstract class Pipeline { abstract headElements(routeData: RouteData): Promise | HeadElements; abstract componentMetadata(routeData: RouteData): Promise | void; + + /** + * It attempts to retrieve the `RouteData` that matches the input `url`, and the component that belongs to the `RouteData`. + * + * ## Errors + * + * - if not `RouteData` is found + * + * @param {RewritePayload} rewritePayload + */ + abstract tryRewrite(rewritePayload: RewritePayload): Promise<[RouteData, ComponentInstance]>; + + /** + * Tells the pipeline how to retrieve a component give a `RouteData` + * @param routeData + */ + abstract getComponentByRoute(routeData: RouteData): Promise; } // eslint-disable-next-line @typescript-eslint/no-empty-interface diff --git a/packages/astro/src/core/build/generate.ts b/packages/astro/src/core/build/generate.ts index ffe799f6e7e4..355d551eaa6c 100644 --- a/packages/astro/src/core/build/generate.ts +++ b/packages/astro/src/core/build/generate.ts @@ -35,24 +35,14 @@ import { getOutputDirectory } from '../../prerender/utils.js'; import type { SSRManifestI18n } from '../app/types.js'; import { NoPrerenderedRoutesWithDomains } from '../errors/errors-data.js'; import { AstroError, AstroErrorData } from '../errors/index.js'; -import { routeIsFallback } from '../redirects/helpers.js'; -import { - RedirectSinglePageBuiltModule, - getRedirectLocationOrThrow, - routeIsRedirect, -} from '../redirects/index.js'; +import { getRedirectLocationOrThrow, routeIsRedirect } from '../redirects/index.js'; import { RenderContext } from '../render-context.js'; import { callGetStaticPaths } from '../render/route-cache.js'; import { createRequest } from '../request.js'; import { matchRoute } from '../routing/match.js'; import { getOutputFilename, isServerLikeOutput } from '../util.js'; import { getOutDirWithinCwd, getOutFile, getOutFolder } from './common.js'; -import { - cssOrder, - getEntryFilePathFromComponentPath, - getPageDataByComponent, - mergeInlineCss, -} from './internal.js'; +import { cssOrder, getPageDataByComponent, mergeInlineCss } from './internal.js'; import { BuildPipeline } from './pipeline.js'; import type { PageBuildData, @@ -66,46 +56,6 @@ function createEntryURL(filePath: string, outFolder: URL) { return new URL('./' + filePath + `?time=${Date.now()}`, outFolder); } -async function getEntryForRedirectRoute( - route: RouteData, - internals: BuildInternals, - outFolder: URL -): Promise { - if (route.type !== 'redirect') { - throw new Error(`Expected a redirect route.`); - } - if (route.redirectRoute) { - const filePath = getEntryFilePathFromComponentPath(internals, route.redirectRoute.component); - if (filePath) { - const url = createEntryURL(filePath, outFolder); - const ssrEntryPage: SinglePageBuiltModule = await import(url.toString()); - return ssrEntryPage; - } - } - - return RedirectSinglePageBuiltModule; -} - -async function getEntryForFallbackRoute( - route: RouteData, - internals: BuildInternals, - outFolder: URL -): Promise { - if (route.type !== 'fallback') { - throw new Error(`Expected a redirect route.`); - } - if (route.redirectRoute) { - const filePath = getEntryFilePathFromComponentPath(internals, route.redirectRoute.component); - if (filePath) { - const url = createEntryURL(filePath, outFolder); - const ssrEntryPage: SinglePageBuiltModule = await import(url.toString()); - return ssrEntryPage; - } - } - - return RedirectSinglePageBuiltModule; -} - // Gives back a facadeId that is relative to the root. // ie, src/pages/index.astro instead of /Users/name..../src/pages/index.astro export function rootRelativeFacadeId(facadeId: string, settings: AstroSettings): string { @@ -185,14 +135,15 @@ export async function generatePages(options: StaticBuildOptions, internals: Buil }); } - const ssrEntryURLPage = createEntryURL(filePath, outFolder); - const ssrEntryPage = await import(ssrEntryURLPage.toString()); + const ssrEntryPage = await pipeline.retrieveSsrEntry(pageData.route, filePath); if (options.settings.adapter?.adapterFeatures?.functionPerRoute) { // forcing to use undefined, so we fail in an expected way if the module is not even there. + // @ts-expect-error When building for `functionPerRoute`, the module exports a `pageModule` function instead const ssrEntry = ssrEntryPage?.pageModule; if (ssrEntry) { await generatePage(pageData, ssrEntry, builtPaths, pipeline); } else { + const ssrEntryURLPage = createEntryURL(filePath, outFolder); throw new Error( `Unable to find the manifest for the module ${ssrEntryURLPage.toString()}. This is unexpected and likely a bug in Astro, please report.` ); @@ -205,18 +156,8 @@ export async function generatePages(options: StaticBuildOptions, internals: Buil } } else { for (const [pageData, filePath] of pagesToGenerate) { - if (routeIsRedirect(pageData.route)) { - const entry = await getEntryForRedirectRoute(pageData.route, internals, outFolder); - await generatePage(pageData, entry, builtPaths, pipeline); - } else if (routeIsFallback(pageData.route)) { - const entry = await getEntryForFallbackRoute(pageData.route, internals, outFolder); - await generatePage(pageData, entry, builtPaths, pipeline); - } else { - const ssrEntryURLPage = createEntryURL(filePath, outFolder); - const entry: SinglePageBuiltModule = await import(ssrEntryURLPage.toString()); - - await generatePage(pageData, entry, builtPaths, pipeline); - } + const entry = await pipeline.retrieveSsrEntry(pageData.route, filePath); + await generatePage(pageData, entry, builtPaths, pipeline); } } logger.info( @@ -232,12 +173,12 @@ export async function generatePages(options: StaticBuildOptions, internals: Buil .map((x) => x.transforms.size) .reduce((a, b) => a + b, 0); const cpuCount = os.cpus().length; - const assetsCreationpipeline = await prepareAssetsGenerationEnv(pipeline, totalCount); + const assetsCreationPipeline = await prepareAssetsGenerationEnv(pipeline, totalCount); const queue = new PQueue({ concurrency: Math.max(cpuCount, 1) }); const assetsTimer = performance.now(); for (const [originalPath, transforms] of staticImageList) { - await generateImagesForPath(originalPath, transforms, assetsCreationpipeline, queue); + await generateImagesForPath(originalPath, transforms, assetsCreationPipeline, queue); } await queue.onIdle(); @@ -615,6 +556,7 @@ function createBuildManifest( i18n: i18nManifest, buildFormat: settings.config.build.format, middleware, + rewritingEnabled: settings.config.experimental.rewriting, checkOrigin: settings.config.experimental.security?.csrfProtection?.origin ?? false, }; } diff --git a/packages/astro/src/core/build/pipeline.ts b/packages/astro/src/core/build/pipeline.ts index a78c8eaf893c..532759f1e426 100644 --- a/packages/astro/src/core/build/pipeline.ts +++ b/packages/astro/src/core/build/pipeline.ts @@ -1,4 +1,10 @@ -import type { RouteData, SSRLoadedRenderer, SSRResult } from '../../@types/astro.js'; +import type { + ComponentInstance, + RewritePayload, + RouteData, + SSRLoadedRenderer, + SSRResult, +} from '../../@types/astro.js'; import { getOutputDirectory } from '../../prerender/utils.js'; import { BEFORE_HYDRATION_SCRIPT_ID, PAGE_SCRIPT_ID } from '../../vite-plugin-scripts/index.js'; import type { SSRManifest } from '../app/types.js'; @@ -13,20 +19,44 @@ import { isServerLikeOutput } from '../util.js'; import { type BuildInternals, cssOrder, + getEntryFilePathFromComponentPath, getPageDataByComponent, mergeInlineCss, } from './internal.js'; import { ASTRO_PAGE_MODULE_ID, ASTRO_PAGE_RESOLVED_MODULE_ID } from './plugins/plugin-pages.js'; import { RESOLVED_SPLIT_MODULE_ID } from './plugins/plugin-ssr.js'; -import { getVirtualModulePageNameFromPath } from './plugins/util.js'; -import { ASTRO_PAGE_EXTENSION_POST_PATTERN } from './plugins/util.js'; -import type { PageBuildData, StaticBuildOptions } from './types.js'; +import { + ASTRO_PAGE_EXTENSION_POST_PATTERN, + getVirtualModulePageNameFromPath, +} from './plugins/util.js'; +import type { PageBuildData, SinglePageBuiltModule, StaticBuildOptions } from './types.js'; import { i18nHasFallback } from './util.js'; +import { RedirectSinglePageBuiltModule } from '../redirects/index.js'; +import { getOutDirWithinCwd } from './common.js'; +import { RouteNotFound } from '../errors/errors-data.js'; +import { AstroError } from '../errors/index.js'; /** * The build pipeline is responsible to gather the files emitted by the SSR build and generate the pages by executing these files. */ export class BuildPipeline extends Pipeline { + #componentsInterner: WeakMap = new WeakMap< + RouteData, + SinglePageBuiltModule + >(); + /** + * This cache is needed to map a single `RouteData` to its file path. + * @private + */ + #routesByFilePath: WeakMap = new WeakMap(); + + get outFolder() { + const ssr = isServerLikeOutput(this.settings.config); + return ssr + ? this.settings.config.build.server + : getOutDirWithinCwd(this.settings.config.outDir); + } + private constructor( readonly internals: BuildInternals, readonly manifest: SSRManifest, @@ -225,6 +255,113 @@ export class BuildPipeline extends Pipeline { } } + for (const [buildData, filePath] of pages.entries()) { + this.#routesByFilePath.set(buildData.route, filePath); + } + return pages; } + + async getComponentByRoute(routeData: RouteData): Promise { + if (this.#componentsInterner.has(routeData)) { + // SAFETY: checked before + const entry = this.#componentsInterner.get(routeData)!; + return await entry.page(); + } else { + // SAFETY: the pipeline calls `retrieveRoutesToGenerate`, which is in charge to fill the cache. + const filePath = this.#routesByFilePath.get(routeData)!; + const module = await this.retrieveSsrEntry(routeData, filePath); + return module.page(); + } + } + + async tryRewrite(payload: RewritePayload): Promise<[RouteData, ComponentInstance]> { + let foundRoute: RouteData | undefined; + // options.manifest is the actual type that contains the information + for (const route of this.options.manifest.routes) { + if (payload instanceof URL) { + if (route.pattern.test(payload.pathname)) { + foundRoute = route; + break; + } + } else if (payload instanceof Request) { + const url = new URL(payload.url); + if (route.pattern.test(url.pathname)) { + foundRoute = route; + break; + } + } else if (route.pattern.test(decodeURI(payload))) { + foundRoute = route; + break; + } + } + if (foundRoute) { + const componentInstance = await this.getComponentByRoute(foundRoute); + return [foundRoute, componentInstance]; + } else { + throw new AstroError(RouteNotFound); + } + } + + async retrieveSsrEntry(route: RouteData, filePath: string): Promise { + if (this.#componentsInterner.has(route)) { + // SAFETY: it is checked inside the if + return this.#componentsInterner.get(route)!; + } + let entry; + if (routeIsRedirect(route)) { + entry = await this.#getEntryForRedirectRoute(route, this.internals, this.outFolder); + } else if (routeIsFallback(route)) { + entry = await this.#getEntryForFallbackRoute(route, this.internals, this.outFolder); + } else { + const ssrEntryURLPage = createEntryURL(filePath, this.outFolder); + entry = await import(ssrEntryURLPage.toString()); + } + this.#componentsInterner.set(route, entry); + return entry; + } + + async #getEntryForFallbackRoute( + route: RouteData, + internals: BuildInternals, + outFolder: URL + ): Promise { + if (route.type !== 'fallback') { + throw new Error(`Expected a redirect route.`); + } + if (route.redirectRoute) { + const filePath = getEntryFilePathFromComponentPath(internals, route.redirectRoute.component); + if (filePath) { + const url = createEntryURL(filePath, outFolder); + const ssrEntryPage: SinglePageBuiltModule = await import(url.toString()); + return ssrEntryPage; + } + } + + return RedirectSinglePageBuiltModule; + } + + async #getEntryForRedirectRoute( + route: RouteData, + internals: BuildInternals, + outFolder: URL + ): Promise { + if (route.type !== 'redirect') { + throw new Error(`Expected a redirect route.`); + } + if (route.redirectRoute) { + const filePath = getEntryFilePathFromComponentPath(internals, route.redirectRoute.component); + if (filePath) { + const url = createEntryURL(filePath, outFolder); + const ssrEntryPage: SinglePageBuiltModule = await import(url.toString()); + return ssrEntryPage; + } + } + + return RedirectSinglePageBuiltModule; + } +} + +function createEntryURL(filePath: string, outFolder: URL) { + return new URL('./' + filePath + `?time=${Date.now()}`, outFolder); } diff --git a/packages/astro/src/core/build/plugins/plugin-manifest.ts b/packages/astro/src/core/build/plugins/plugin-manifest.ts index 498ccdbb544b..5bb6ddab038a 100644 --- a/packages/astro/src/core/build/plugins/plugin-manifest.ts +++ b/packages/astro/src/core/build/plugins/plugin-manifest.ts @@ -277,5 +277,6 @@ function buildManifest( i18n: i18nManifest, buildFormat: settings.config.build.format, checkOrigin: settings.config.experimental.security?.csrfProtection?.origin ?? false, + rewritingEnabled: settings.config.experimental.rewriting, }; } diff --git a/packages/astro/src/core/config/schema.ts b/packages/astro/src/core/config/schema.ts index 303846f7608f..0fd4c58e66fd 100644 --- a/packages/astro/src/core/config/schema.ts +++ b/packages/astro/src/core/config/schema.ts @@ -87,6 +87,7 @@ const ASTRO_CONFIG_DEFAULTS = { globalRoutePriority: false, i18nDomains: false, security: {}, + rewriting: false, }, } satisfies AstroUserConfig & { server: { open: boolean } }; @@ -525,6 +526,7 @@ export const AstroConfigSchema = z.object({ .optional() .default(ASTRO_CONFIG_DEFAULTS.experimental.security), i18nDomains: z.boolean().optional().default(ASTRO_CONFIG_DEFAULTS.experimental.i18nDomains), + rewriting: z.boolean().optional().default(ASTRO_CONFIG_DEFAULTS.experimental.rewriting), }) .strict( `Invalid or outdated experimental feature.\nCheck for incorrect spelling or outdated Astro version.\nSee https://docs.astro.build/en/reference/configuration-reference/#experimental-flags for a list of all current experiments.` diff --git a/packages/astro/src/core/errors/errors-data.ts b/packages/astro/src/core/errors/errors-data.ts index 180160064ab1..7ebc3a3831a1 100644 --- a/packages/astro/src/core/errors/errors-data.ts +++ b/packages/astro/src/core/errors/errors-data.ts @@ -1483,6 +1483,18 @@ export const UnsupportedConfigTransformError = { hint: 'See the devalue library for all supported types: https://github.com/rich-harris/devalue', } satisfies ErrorData; +/** + * @docs + * @description + * + * Astro couldn't find a route matching the one provided by the user + */ +export const RouteNotFound = { + name: 'RouteNotFound', + title: 'Route not found.', + message: `Astro could find a route that matches the one you requested.`, +} satisfies ErrorData; + // Generic catch-all - Only use this in extreme cases, like if there was a cosmic ray bit flip. export const UnknownError = { name: 'UnknownError', title: 'Unknown Error.' } satisfies ErrorData; diff --git a/packages/astro/src/core/middleware/callMiddleware.ts b/packages/astro/src/core/middleware/callMiddleware.ts index 0133c13d032d..b92e0f3cb19b 100644 --- a/packages/astro/src/core/middleware/callMiddleware.ts +++ b/packages/astro/src/core/middleware/callMiddleware.ts @@ -1,5 +1,11 @@ -import type { APIContext, MiddlewareHandler, MiddlewareNext } from '../../@types/astro.js'; +import type { + APIContext, + MiddlewareHandler, + MiddlewareNext, + RewritePayload, +} from '../../@types/astro.js'; import { AstroError, AstroErrorData } from '../errors/index.js'; +import type { Logger } from '../logger/core.js'; /** * Utility function that is in charge of calling the middleware. @@ -38,13 +44,28 @@ import { AstroError, AstroErrorData } from '../errors/index.js'; export async function callMiddleware( onRequest: MiddlewareHandler, apiContext: APIContext, - responseFunction: () => Promise | Response + responseFunction: ( + apiContext: APIContext, + rewritePayload?: RewritePayload + ) => Promise | Response, + // TODO: remove these two arguments once rerouting goes out of experimental + enableRerouting: boolean, + logger: Logger ): Promise { let nextCalled = false; let responseFunctionPromise: Promise | Response | undefined = undefined; - const next: MiddlewareNext = async () => { + const next: MiddlewareNext = async (payload) => { nextCalled = true; - responseFunctionPromise = responseFunction(); + if (enableRerouting) { + responseFunctionPromise = responseFunction(apiContext, payload); + } else { + logger.warn( + 'router', + 'The rewrite API is experimental. To use this feature, add the `rewriting` flag to the `experimental` object in your Astro config.' + ); + responseFunctionPromise = responseFunction(apiContext); + } + // We need to pass the APIContext pass to `callMiddleware` because it can be mutated across middleware functions return responseFunctionPromise; }; diff --git a/packages/astro/src/core/middleware/index.ts b/packages/astro/src/core/middleware/index.ts index cb9304bffbe1..17c206d6e9f5 100644 --- a/packages/astro/src/core/middleware/index.ts +++ b/packages/astro/src/core/middleware/index.ts @@ -1,17 +1,14 @@ -import type { APIContext, MiddlewareHandler, Params } from '../../@types/astro.js'; +import type { APIContext, MiddlewareHandler, Params, RewritePayload } from '../../@types/astro.js'; import { computeCurrentLocale, computePreferredLocale, computePreferredLocaleList, } from '../../i18n/utils.js'; -import { ASTRO_VERSION } from '../constants.js'; +import { ASTRO_VERSION, clientLocalsSymbol, clientAddressSymbol } from '../constants.js'; import { AstroCookies } from '../cookies/index.js'; import { AstroError, AstroErrorData } from '../errors/index.js'; import { sequence } from './sequence.js'; -const clientAddressSymbol = Symbol.for('astro.clientAddress'); -const clientLocalsSymbol = Symbol.for('astro.locals'); - function defineMiddleware(fn: MiddlewareHandler) { return fn; } @@ -49,6 +46,12 @@ function createContext({ const url = new URL(request.url); const route = url.pathname; + // TODO verify that this function works in an edge middleware environment + const reroute = (_reroutePayload: RewritePayload) => { + // return dummy response + return Promise.resolve(new Response(null)); + }; + return { cookies: new AstroCookies(request), request, @@ -56,6 +59,7 @@ function createContext({ site: undefined, generator: `Astro v${ASTRO_VERSION}`, props: {}, + rewrite: reroute, redirect(path, status) { return new Response(null, { status: status || 302, diff --git a/packages/astro/src/core/middleware/sequence.ts b/packages/astro/src/core/middleware/sequence.ts index 9a68963945ec..ef27d03c2cb4 100644 --- a/packages/astro/src/core/middleware/sequence.ts +++ b/packages/astro/src/core/middleware/sequence.ts @@ -1,5 +1,6 @@ -import type { APIContext, MiddlewareHandler } from '../../@types/astro.js'; +import type { APIContext, MiddlewareHandler, RewritePayload } from '../../@types/astro.js'; import { defineMiddleware } from './index.js'; +import { AstroCookies } from '../cookies/cookies.js'; // From SvelteKit: https://github.com/sveltejs/kit/blob/master/packages/kit/src/exports/hooks/sequence.js /** @@ -10,13 +11,16 @@ export function sequence(...handlers: MiddlewareHandler[]): MiddlewareHandler { const filtered = handlers.filter((h) => !!h); const length = filtered.length; if (!length) { - const handler: MiddlewareHandler = defineMiddleware((context, next) => { + return defineMiddleware((_context, next) => { return next(); }); - return handler; } return defineMiddleware((context, next) => { + /** + * This variable is used to carry the rerouting payload across middleware functions. + */ + let carriedPayload: RewritePayload | undefined = undefined; return applyHandle(0, context); function applyHandle(i: number, handleContext: APIContext) { @@ -24,11 +28,28 @@ export function sequence(...handlers: MiddlewareHandler[]): MiddlewareHandler { // @ts-expect-error // SAFETY: Usually `next` always returns something in user land, but in `sequence` we are actually // doing a loop over all the `next` functions, and eventually we call the last `next` that returns the `Response`. - const result = handle(handleContext, async () => { + const result = handle(handleContext, async (payload: RewritePayload) => { if (i < length - 1) { + if (payload) { + let newRequest; + if (payload instanceof Request) { + newRequest = payload; + } else if (payload instanceof URL) { + newRequest = new Request(payload, handleContext.request); + } else { + newRequest = new Request( + new URL(payload, handleContext.url.origin), + handleContext.request + ); + } + carriedPayload = payload; + handleContext.request = newRequest; + handleContext.url = new URL(newRequest.url); + handleContext.cookies = new AstroCookies(newRequest); + } return applyHandle(i + 1, handleContext); } else { - return next(); + return next(payload ?? carriedPayload); } }); return result; diff --git a/packages/astro/src/core/render-context.ts b/packages/astro/src/core/render-context.ts index 5cfc8ef2ede3..279745ac19e4 100644 --- a/packages/astro/src/core/render-context.ts +++ b/packages/astro/src/core/render-context.ts @@ -4,6 +4,8 @@ import type { AstroGlobalPartial, ComponentInstance, MiddlewareHandler, + MiddlewareNext, + RewritePayload, RouteData, SSRResult, } from '../@types/astro.js'; @@ -39,14 +41,23 @@ export class RenderContext { public locals: App.Locals, readonly middleware: MiddlewareHandler, readonly pathname: string, - readonly request: Request, - readonly routeData: RouteData, + public request: Request, + public routeData: RouteData, public status: number, - readonly cookies = new AstroCookies(request), - readonly params = getParams(routeData, pathname), - readonly url = new URL(request.url) + protected cookies = new AstroCookies(request), + public params = getParams(routeData, pathname), + protected url = new URL(request.url) ) {} + /** + * A flag that tells the render content if the rewriting was triggered + */ + isRewriting = false; + /** + * A safety net in case of loops + */ + counter = 0; + static create({ locals = {}, middleware, @@ -56,7 +67,7 @@ export class RenderContext { routeData, status = 200, }: Pick & - Partial>) { + Partial>): RenderContext { return new RenderContext( pipeline, locals, @@ -80,11 +91,11 @@ export class RenderContext { * - fallback */ async render(componentInstance: ComponentInstance | undefined): Promise { - const { cookies, middleware, pathname, pipeline, routeData } = this; + const { cookies, middleware, pathname, pipeline } = this; const { logger, routeCache, serverLike, streaming } = pipeline; const props = await getProps({ mod: componentInstance, - routeData, + routeData: this.routeData, routeCache, pathname, logger, @@ -92,10 +103,40 @@ export class RenderContext { }); const apiContext = this.createAPIContext(props); - const lastNext = async () => { - switch (routeData.type) { + 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: APIContext, payload?: RewritePayload) => { + if (payload) { + if (this.pipeline.manifest.rewritingEnabled) { + try { + const [routeData, component] = await pipeline.tryRewrite(payload); + this.routeData = routeData; + componentInstance = component; + } catch (e) { + return new Response('Not found', { + status: 404, + statusText: 'Not found', + }); + } finally { + this.isRewriting = true; + } + } else { + this.pipeline.logger.warn( + 'router', + 'The rewrite API is experimental. To use this feature, add the `rewriting` flag to the `experimental` object in your Astro config.' + ); + } + } + switch (this.routeData.type) { case 'endpoint': - return renderEndpoint(componentInstance as any, apiContext, serverLike, logger); + return renderEndpoint(componentInstance as any, ctx, serverLike, logger); case 'redirect': return renderRedirect(this); case 'page': { @@ -108,7 +149,7 @@ export class RenderContext { props, {}, streaming, - routeData + this.routeData ); } catch (e) { // If there is an error in the page's frontmatter or instantiation of the RenderTemplate fails midway, @@ -119,7 +160,11 @@ export class RenderContext { // Signal to the i18n middleware to maybe act on this response response.headers.set(ROUTE_TYPE_HEADER, 'page'); // Signal to the error-page-rerouting infra to let this response pass through to avoid loops - if (routeData.route === '/404' || routeData.route === '/500') { + if ( + this.routeData.route === '/404' || + this.routeData.route === '/500' || + this.isRewriting + ) { response.headers.set(REROUTE_DIRECTIVE_HEADER, 'no'); } return response; @@ -130,7 +175,13 @@ export class RenderContext { } }; - const response = await callMiddleware(middleware, apiContext, lastNext); + const response = await callMiddleware( + middleware, + apiContext, + lastNext, + this.pipeline.manifest.rewritingEnabled, + this.pipeline.logger + ); if (response.headers.get(ROUTE_TYPE_HEADER)) { response.headers.delete(ROUTE_TYPE_HEADER); } @@ -143,10 +194,38 @@ export class RenderContext { createAPIContext(props: APIContext['props']): APIContext { const renderContext = this; - const { cookies, params, pipeline, request, url } = this; + const { cookies, params, pipeline, url } = this; const generator = `Astro v${ASTRO_VERSION}`; const redirect = (path: string, status = 302) => new Response(null, { status, headers: { Location: path } }); + + const rewrite = async (reroutePayload: RewritePayload) => { + pipeline.logger.debug('router', 'Called rewriting to:', reroutePayload); + try { + const [routeData, component] = await pipeline.tryRewrite(reroutePayload); + this.routeData = routeData; + if (reroutePayload instanceof Request) { + this.request = reroutePayload; + } else { + this.request = new Request( + new URL(routeData.pathname ?? routeData.route, this.url.origin), + this.request + ); + } + this.url = new URL(this.request.url); + this.cookies = new AstroCookies(this.request); + this.params = getParams(routeData, url.toString()); + this.isRewriting = true; + return await this.render(component); + } catch (e) { + pipeline.logger.debug('router', 'Rewrite failed.', e); + return new Response('Not found', { + status: 404, + statusText: 'Not found', + }); + } + }; + return { cookies, get clientAddress() { @@ -167,7 +246,7 @@ export class RenderContext { renderContext.locals = val; // we also put it on the original Request object, // where the adapter might be expecting to read it after the response. - Reflect.set(request, clientLocalsSymbol, val); + Reflect.set(this.request, clientLocalsSymbol, val); } }, params, @@ -179,7 +258,8 @@ export class RenderContext { }, props, redirect, - request, + rewrite, + request: this.request, site: pipeline.site, url, }; @@ -294,11 +374,11 @@ export class RenderContext { astroStaticPartial: AstroGlobalPartial ): Omit { const renderContext = this; - const { cookies, locals, params, pipeline, request, url } = this; + const { cookies, locals, params, pipeline, url } = this; const { response } = result; const redirect = (path: string, status = 302) => { // If the response is already sent, error as we cannot proceed with the redirect. - if ((request as any)[responseSentSymbol]) { + if ((this.request as any)[responseSentSymbol]) { throw new AstroError({ ...AstroErrorData.ResponseSentError, }); @@ -306,6 +386,33 @@ export class RenderContext { return new Response(null, { status, headers: { Location: path } }); }; + const rewrite = async (reroutePayload: RewritePayload) => { + try { + pipeline.logger.debug('router', 'Calling rewrite: ', reroutePayload); + const [routeData, component] = await pipeline.tryRewrite(reroutePayload); + this.routeData = routeData; + if (reroutePayload instanceof Request) { + this.request = reroutePayload; + } else { + this.request = new Request( + new URL(routeData.pathname ?? routeData.route, this.url.origin), + this.request + ); + } + this.url = new URL(this.request.url); + this.cookies = new AstroCookies(this.request); + this.params = getParams(routeData, url.toString()); + this.isRewriting = true; + return await this.render(component); + } catch (e) { + pipeline.logger.debug('router', 'Rerouting failed, returning a 404.', e); + return new Response('Not found', { + status: 404, + statusText: 'Not found', + }); + } + }; + return { generator: astroStaticPartial.generator, glob: astroStaticPartial.glob, @@ -325,7 +432,8 @@ export class RenderContext { }, locals, redirect, - request, + rewrite, + request: this.request, response, site: pipeline.site, url, diff --git a/packages/astro/src/prerender/routing.ts b/packages/astro/src/prerender/routing.ts index e6c09dd70279..cbdddff5c8cb 100644 --- a/packages/astro/src/prerender/routing.ts +++ b/packages/astro/src/prerender/routing.ts @@ -54,7 +54,7 @@ async function preloadAndSetPrerenderStatus({ continue; } - const preloadedComponent = await pipeline.preload(filePath); + const preloadedComponent = await pipeline.preload(route, filePath); // gets the prerender metadata set by the `astro:scanner` vite plugin const prerenderStatus = getPrerenderStatus({ diff --git a/packages/astro/src/vite-plugin-astro-server/pipeline.ts b/packages/astro/src/vite-plugin-astro-server/pipeline.ts index 7ccc63638284..685d13f570c4 100644 --- a/packages/astro/src/vite-plugin-astro-server/pipeline.ts +++ b/packages/astro/src/vite-plugin-astro-server/pipeline.ts @@ -1,8 +1,10 @@ -import url from 'node:url'; +import { fileURLToPath } from 'node:url'; import type { AstroSettings, ComponentInstance, DevToolbarMetadata, + ManifestData, + RewritePayload, RouteData, SSRElement, SSRLoadedRenderer, @@ -12,10 +14,10 @@ import { getInfoOutput } from '../cli/info/index.js'; import type { HeadElements } from '../core/base-pipeline.js'; import { ASTRO_VERSION, DEFAULT_404_COMPONENT } from '../core/constants.js'; import { enhanceViteSSRError } from '../core/errors/dev/index.js'; -import { AggregateError, CSSError, MarkdownError } from '../core/errors/index.js'; +import { AggregateError, AstroError, CSSError, MarkdownError } from '../core/errors/index.js'; import type { Logger } from '../core/logger/core.js'; import type { ModuleLoader } from '../core/module-loader/index.js'; -import { Pipeline, loadRenderer } from '../core/render/index.js'; +import { loadRenderer, Pipeline } from '../core/render/index.js'; import { isPage, isServerLikeOutput, resolveIdToUrl, viteID } from '../core/util.js'; import { PAGE_SCRIPT_ID } from '../vite-plugin-scripts/index.js'; import { getStylesForURL } from './css.js'; @@ -23,12 +25,20 @@ import { getComponentMetadata } from './metadata.js'; import { createResolve } from './resolve.js'; import { default404Page } from './response.js'; import { getScriptsForURL } from './scripts.js'; +import { RouteNotFound } from '../core/errors/errors-data.js'; export class DevPipeline extends Pipeline { // renderers are loaded on every request, // so it needs to be mutable here unlike in other environments override renderers = new Array(); + manifestData: ManifestData | undefined; + + componentInterner: WeakMap = new WeakMap< + RouteData, + ComponentInstance + >(); + private constructor( readonly loader: ModuleLoader, readonly logger: Logger, @@ -43,13 +53,18 @@ export class DevPipeline extends Pipeline { super(logger, manifest, mode, [], resolve, serverLike, streaming); } - static create({ - loader, - logger, - manifest, - settings, - }: Pick) { - return new DevPipeline(loader, logger, manifest, settings); + static create( + manifestData: ManifestData, + { + loader, + logger, + manifest, + settings, + }: Pick + ) { + const pipeline = new DevPipeline(loader, logger, manifest, settings); + pipeline.manifestData = manifestData; + return pipeline; } async headElements(routeData: RouteData): Promise { @@ -59,7 +74,7 @@ export class DevPipeline extends Pipeline { mode, settings, } = this; - const filePath = new URL(`./${routeData.component}`, root); + const filePath = new URL(`${routeData.component}`, root); // Add hoisted script tags, skip if direct rendering with `directRenderScript` const { scripts } = settings.config.experimental.directRenderScript ? { scripts: new Set() } @@ -80,7 +95,7 @@ export class DevPipeline extends Pipeline { scripts.add({ props: { type: 'module', src }, children: '' }); const additionalMetadata: DevToolbarMetadata['__astro_dev_toolbar__'] = { - root: url.fileURLToPath(settings.config.root), + root: fileURLToPath(settings.config.root), version: ASTRO_VERSION, latestAstroVersion: settings.latestAstroVersion, debugInfo: await getInfoOutput({ userConfig: settings.config, print: false }), @@ -131,11 +146,11 @@ export class DevPipeline extends Pipeline { config: { root }, loader, } = this; - const filePath = new URL(`./${routeData.component}`, root); + const filePath = new URL(`${routeData.component}`, root); return getComponentMetadata(filePath, loader); } - async preload(filePath: URL) { + async preload(routeData: RouteData, filePath: URL) { const { loader } = this; if (filePath.href === new URL(DEFAULT_404_COMPONENT, this.config.root).href) { return { default: default404Page } as any as ComponentInstance; @@ -148,7 +163,9 @@ export class DevPipeline extends Pipeline { try { // Load the module from the Vite SSR Runtime. - return (await loader.import(viteID(filePath))) as ComponentInstance; + const componentInstance = (await loader.import(viteID(filePath))) as ComponentInstance; + this.componentInterner.set(routeData, componentInstance); + return componentInstance; } catch (error) { // If the error came from Markdown or CSS, we already handled it and there's no need to enhance it if (MarkdownError.is(error) || CSSError.is(error) || AggregateError.is(error)) { @@ -161,5 +178,52 @@ export class DevPipeline extends Pipeline { clearRouteCache() { this.routeCache.clearAll(); + this.componentInterner = new WeakMap(); + } + + async getComponentByRoute(routeData: RouteData): Promise { + const component = this.componentInterner.get(routeData); + if (component) { + return component; + } else { + const filePath = new URL(`${routeData.component}`, this.config.root); + return await this.preload(routeData, filePath); + } + } + + async tryRewrite(payload: RewritePayload): Promise<[RouteData, ComponentInstance]> { + let foundRoute; + if (!this.manifestData) { + throw new Error('Missing manifest data. This is an internal error, please file an issue.'); + } + + for (const route of this.manifestData.routes) { + if (payload instanceof URL) { + if (route.pattern.test(payload.pathname)) { + foundRoute = route; + break; + } + } else if (payload instanceof Request) { + const url = new URL(payload.url); + if (route.pattern.test(url.pathname)) { + foundRoute = route; + break; + } + } else if (route.pattern.test(decodeURI(payload))) { + foundRoute = route; + break; + } + } + + if (foundRoute) { + const componentInstance = await this.getComponentByRoute(foundRoute); + return [foundRoute, componentInstance]; + } else { + throw new AstroError(RouteNotFound); + } + } + + setManifestData(manifestData: ManifestData) { + this.manifestData = manifestData; } } diff --git a/packages/astro/src/vite-plugin-astro-server/plugin.ts b/packages/astro/src/vite-plugin-astro-server/plugin.ts index 082de6bcebf0..3c6f06ee9a05 100644 --- a/packages/astro/src/vite-plugin-astro-server/plugin.ts +++ b/packages/astro/src/vite-plugin-astro-server/plugin.ts @@ -35,10 +35,10 @@ export default function createVitePluginAstroServer({ configureServer(viteServer) { const loader = createViteLoader(viteServer); const manifest = createDevelopmentManifest(settings); - const pipeline = DevPipeline.create({ loader, logger, manifest, settings }); let manifestData: ManifestData = ensure404Route( createRouteManifest({ settings, fsMod }, logger) ); + const pipeline = DevPipeline.create(manifestData, { loader, logger, manifest, settings }); const controller = createController({ loader }); const localStorage = new AsyncLocalStorage(); @@ -47,6 +47,7 @@ export default function createVitePluginAstroServer({ pipeline.clearRouteCache(); if (needsManifestRebuild) { manifestData = ensure404Route(createRouteManifest({ settings }, logger)); + pipeline.setManifestData(manifestData); } } // Rebuild route manifest on file change, if needed. @@ -144,6 +145,7 @@ export function createDevelopmentManifest(settings: AstroSettings): SSRManifest inlinedScripts: new Map(), i18n: i18nManifest, checkOrigin: settings.config.experimental.security?.csrfProtection?.origin ?? false, + rewritingEnabled: settings.config.experimental.rewriting, middleware(_, next) { return next(); }, diff --git a/packages/astro/src/vite-plugin-astro-server/route.ts b/packages/astro/src/vite-plugin-astro-server/route.ts index 21053420a754..85bf969f9db9 100644 --- a/packages/astro/src/vite-plugin-astro-server/route.ts +++ b/packages/astro/src/vite-plugin-astro-server/route.ts @@ -114,7 +114,7 @@ export async function matchRoute( if (custom404) { const filePath = new URL(`./${custom404.component}`, config.root); - const preloadedComponent = await pipeline.preload(filePath); + const preloadedComponent = await pipeline.preload(custom404, filePath); return { route: custom404, @@ -197,40 +197,39 @@ export async function handleRoute({ if (!pathNameHasLocale && pathname !== '/') { return handle404Response(origin, incomingRequest, incomingResponse); } - request = createRequest({ - base: config.base, - url, - headers: incomingRequest.headers, - logger, - // no route found, so we assume the default for rendering the 404 page - staticLike: config.output === 'static' || config.output === 'hybrid', - }); - route = { - component: '', - generate(_data: any): string { - return ''; - }, - params: [], - // Disable eslint as we only want to generate an empty RegExp - // eslint-disable-next-line prefer-regex-literals - pattern: new RegExp(''), - prerender: false, - segments: [], - type: 'fallback', - route: '', - fallbackRoutes: [], - isIndex: false, - }; - renderContext = RenderContext.create({ - pipeline: pipeline, - pathname, - middleware, - request, - routeData: route, - }); - } else { - return handle404Response(origin, incomingRequest, incomingResponse); } + request = createRequest({ + base: config.base, + url, + headers: incomingRequest.headers, + logger, + // no route found, so we assume the default for rendering the 404 page + staticLike: config.output === 'static' || config.output === 'hybrid', + }); + route = { + component: '', + generate(_data: any): string { + return ''; + }, + params: [], + // Disable eslint as we only want to generate an empty RegExp + // eslint-disable-next-line prefer-regex-literals + pattern: new RegExp(''), + prerender: false, + segments: [], + type: 'fallback', + route: '', + fallbackRoutes: [], + isIndex: false, + }; + + renderContext = RenderContext.create({ + pipeline: pipeline, + pathname, + middleware, + request, + routeData: route, + }); } else { const filePath: URL | undefined = matchedRoute.filePath; const { preloadedComponent } = matchedRoute; diff --git a/packages/astro/test/fixtures/middleware-virtual/astro.config.mjs b/packages/astro/test/fixtures/middleware-virtual/astro.config.mjs new file mode 100644 index 000000000000..bc095ecddb69 --- /dev/null +++ b/packages/astro/test/fixtures/middleware-virtual/astro.config.mjs @@ -0,0 +1,3 @@ +import { defineConfig } from "astro/config"; + +export default defineConfig({}) diff --git a/packages/astro/test/fixtures/middleware-virtual/package.json b/packages/astro/test/fixtures/middleware-virtual/package.json new file mode 100644 index 000000000000..7cfbeb721047 --- /dev/null +++ b/packages/astro/test/fixtures/middleware-virtual/package.json @@ -0,0 +1,8 @@ +{ + "name": "@test/middleware-virtual", + "version": "0.0.0", + "private": true, + "dependencies": { + "astro": "workspace:*" + } +} diff --git a/packages/astro/test/fixtures/middleware-virtual/src/middleware.js b/packages/astro/test/fixtures/middleware-virtual/src/middleware.js new file mode 100644 index 000000000000..55004a00cfdb --- /dev/null +++ b/packages/astro/test/fixtures/middleware-virtual/src/middleware.js @@ -0,0 +1,6 @@ +import { defineMiddleware } from 'astro:middleware'; + +export const onRequest = defineMiddleware(async (context, next) => { + console.log('[MIDDLEWARE] in ' + context.url.toString()); + return next(); +}); diff --git a/packages/astro/test/fixtures/middleware-virtual/src/pages/index.astro b/packages/astro/test/fixtures/middleware-virtual/src/pages/index.astro new file mode 100644 index 000000000000..9bd31f5fde27 --- /dev/null +++ b/packages/astro/test/fixtures/middleware-virtual/src/pages/index.astro @@ -0,0 +1,13 @@ +--- +const data = Astro.locals; +--- + + + + Index + + + +Index + + diff --git a/packages/astro/test/fixtures/reroute/astro.config.mjs b/packages/astro/test/fixtures/reroute/astro.config.mjs new file mode 100644 index 000000000000..af13ef19b477 --- /dev/null +++ b/packages/astro/test/fixtures/reroute/astro.config.mjs @@ -0,0 +1,9 @@ +import { defineConfig } from 'astro/config'; + +// https://astro.build/config +export default defineConfig({ + experimental: { + rewriting: true + }, + site: "https://example.com" +}); diff --git a/packages/astro/test/fixtures/reroute/package.json b/packages/astro/test/fixtures/reroute/package.json new file mode 100644 index 000000000000..ed64e57a97e0 --- /dev/null +++ b/packages/astro/test/fixtures/reroute/package.json @@ -0,0 +1,8 @@ +{ + "name": "@test/reroute", + "version": "0.0.0", + "private": true, + "dependencies": { + "astro": "workspace:*" + } +} diff --git a/packages/astro/test/fixtures/reroute/src/middleware.js b/packages/astro/test/fixtures/reroute/src/middleware.js new file mode 100644 index 000000000000..4d7c2a7956c8 --- /dev/null +++ b/packages/astro/test/fixtures/reroute/src/middleware.js @@ -0,0 +1,33 @@ +import { sequence } from 'astro:middleware'; + +let contextReroute = false; + +export const first = async (context, next) => { + if (context.url.pathname.includes('/auth')) { + } + + return next(); +}; + +export const second = async (context, next) => { + if (context.url.pathname.includes('/auth')) { + if (context.url.pathname.includes('/auth/dashboard')) { + contextReroute = true; + return await context.rewrite('/'); + } + if (context.url.pathname.includes('/auth/base')) { + return await next('/'); + } + } + return next(); +}; + +export const third = async (context, next) => { + // just making sure that we are testing the change in context coming from `next()` + if (context.url.pathname.startsWith('/') && contextReroute === false) { + context.locals.auth = 'Third function called'; + } + return next(); +}; + +export const onRequest = sequence(first, second, third); diff --git a/packages/astro/test/fixtures/reroute/src/pages/auth/base.astro b/packages/astro/test/fixtures/reroute/src/pages/auth/base.astro new file mode 100644 index 000000000000..be31dfb14141 --- /dev/null +++ b/packages/astro/test/fixtures/reroute/src/pages/auth/base.astro @@ -0,0 +1,10 @@ +--- +--- + + + Base + + +

Base

+ + diff --git a/packages/astro/test/fixtures/reroute/src/pages/auth/dashboard.astro b/packages/astro/test/fixtures/reroute/src/pages/auth/dashboard.astro new file mode 100644 index 000000000000..bfa006aa01a7 --- /dev/null +++ b/packages/astro/test/fixtures/reroute/src/pages/auth/dashboard.astro @@ -0,0 +1,10 @@ +--- +--- + + + Dashboard + + +

Dashboard

+ + diff --git a/packages/astro/test/fixtures/reroute/src/pages/auth/settings.astro b/packages/astro/test/fixtures/reroute/src/pages/auth/settings.astro new file mode 100644 index 000000000000..9eee5fe95149 --- /dev/null +++ b/packages/astro/test/fixtures/reroute/src/pages/auth/settings.astro @@ -0,0 +1,10 @@ +--- +--- + + + Settings + + +

Settings

+ + diff --git a/packages/astro/test/fixtures/reroute/src/pages/blog/hello/index.astro b/packages/astro/test/fixtures/reroute/src/pages/blog/hello/index.astro new file mode 100644 index 000000000000..8c38e518a7b7 --- /dev/null +++ b/packages/astro/test/fixtures/reroute/src/pages/blog/hello/index.astro @@ -0,0 +1,11 @@ +--- +return Astro.rewrite(new URL("../../", Astro.url)) +--- + + + Blog hello + + +

Blog hello

+ + diff --git a/packages/astro/test/fixtures/reroute/src/pages/blog/oops.astro b/packages/astro/test/fixtures/reroute/src/pages/blog/oops.astro new file mode 100644 index 000000000000..df1f1f76a331 --- /dev/null +++ b/packages/astro/test/fixtures/reroute/src/pages/blog/oops.astro @@ -0,0 +1,11 @@ +--- +return Astro.rewrite("/404") +--- + + + Blog hello + + +

Blog hello

+ + diff --git a/packages/astro/test/fixtures/reroute/src/pages/blog/salut/index.astro b/packages/astro/test/fixtures/reroute/src/pages/blog/salut/index.astro new file mode 100644 index 000000000000..89d35ce2564d --- /dev/null +++ b/packages/astro/test/fixtures/reroute/src/pages/blog/salut/index.astro @@ -0,0 +1,11 @@ +--- +return Astro.rewrite(new Request(new URL("../../", Astro.url))) +--- + + + Blog hello + + +

Blog hello

+ + diff --git a/packages/astro/test/fixtures/reroute/src/pages/dynamic/[id].astro b/packages/astro/test/fixtures/reroute/src/pages/dynamic/[id].astro new file mode 100644 index 000000000000..8d849de160bf --- /dev/null +++ b/packages/astro/test/fixtures/reroute/src/pages/dynamic/[id].astro @@ -0,0 +1,21 @@ +--- + +export function getStaticPaths() { + return [ + { params: { id: 'hello' } }, + ]; +} + + +return Astro.rewrite("/") + +--- + + + + Dynamic [id].astro + + +

/dynamic/[id].astro

+ + diff --git a/packages/astro/test/fixtures/reroute/src/pages/index.astro b/packages/astro/test/fixtures/reroute/src/pages/index.astro new file mode 100644 index 000000000000..91a6fd0fb0fc --- /dev/null +++ b/packages/astro/test/fixtures/reroute/src/pages/index.astro @@ -0,0 +1,12 @@ +--- +const auth = Astro.locals.auth; +--- + + + Index + + +

Index

+ {auth ?

Called auth

: ""} + + diff --git a/packages/astro/test/fixtures/reroute/src/pages/reroute.astro b/packages/astro/test/fixtures/reroute/src/pages/reroute.astro new file mode 100644 index 000000000000..dbc7a6ae628a --- /dev/null +++ b/packages/astro/test/fixtures/reroute/src/pages/reroute.astro @@ -0,0 +1,11 @@ +--- +return Astro.rewrite("/") +--- + + + Reroute + + +

Reroute

+ + diff --git a/packages/astro/test/fixtures/reroute/src/pages/spread/[...id].astro b/packages/astro/test/fixtures/reroute/src/pages/spread/[...id].astro new file mode 100644 index 000000000000..0bab88d0f7b1 --- /dev/null +++ b/packages/astro/test/fixtures/reroute/src/pages/spread/[...id].astro @@ -0,0 +1,20 @@ +--- +export function getStaticPaths() { + return [ + { params: { id: 'hello' } }, + ]; +} + +return Astro.rewrite("/") + +--- + + + + + Spread [...id].astro + + +

/spread/[...id].astro

+ + diff --git a/packages/astro/test/i18n-routing-manual.test.js b/packages/astro/test/i18n-routing-manual.test.js index d664b3797889..1feaf963348c 100644 --- a/packages/astro/test/i18n-routing-manual.test.js +++ b/packages/astro/test/i18n-routing-manual.test.js @@ -58,8 +58,6 @@ describe('Dev server manual routing', () => { describe('SSG manual routing', () => { /** @type {import('./test-utils').Fixture} */ let fixture; - /** @type {import('./test-utils').DevServer} */ - let devServer; before(async () => { fixture = await loadFixture({ diff --git a/packages/astro/test/rewrite.test.js b/packages/astro/test/rewrite.test.js new file mode 100644 index 000000000000..39ff084a4c80 --- /dev/null +++ b/packages/astro/test/rewrite.test.js @@ -0,0 +1,223 @@ +import { describe, it, before, after } from 'node:test'; +import { loadFixture } from './test-utils.js'; +import { load as cheerioLoad } from 'cheerio'; +import assert from 'node:assert/strict'; +import testAdapter from './test-adapter.js'; + +describe('Dev reroute', () => { + /** @type {import('./test-utils').Fixture} */ + let fixture; + let devServer; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/reroute/', + }); + devServer = await fixture.startDevServer(); + }); + + after(async () => { + await devServer.stop(); + }); + + it('should render the index page when navigating /reroute ', async () => { + const html = await fixture.fetch('/reroute').then((res) => res.text()); + const $ = cheerioLoad(html); + + assert.equal($('h1').text(), 'Index'); + }); + + it('should render the index page when navigating /blog/hello ', async () => { + const html = await fixture.fetch('/blog/hello').then((res) => res.text()); + const $ = cheerioLoad(html); + + assert.equal($('h1').text(), 'Index'); + }); + + it('should render the index page when navigating /blog/salut ', async () => { + const html = await fixture.fetch('/blog/salut').then((res) => res.text()); + const $ = cheerioLoad(html); + + assert.equal($('h1').text(), 'Index'); + }); + + it('should render the index page when navigating dynamic route /dynamic/[id] ', async () => { + const html = await fixture.fetch('/dynamic/hello').then((res) => res.text()); + const $ = cheerioLoad(html); + + assert.equal($('h1').text(), 'Index'); + }); + + it('should render the index page when navigating spread route /spread/[...spread] ', async () => { + const html = await fixture.fetch('/spread/hello').then((res) => res.text()); + const $ = cheerioLoad(html); + + assert.equal($('h1').text(), 'Index'); + }); + + it('should render the 404 built-in page', async () => { + const html = await fixture.fetch('/blog/oops').then((res) => res.text()); + const $ = cheerioLoad(html); + + assert.equal($('h1').text(), '404: Not found'); + }); +}); + +describe('Build reroute', () => { + /** @type {import('./test-utils').Fixture} */ + let fixture; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/reroute/', + }); + await fixture.build(); + }); + + it('should render the index page when navigating /reroute ', async () => { + const html = await fixture.readFile('/reroute/index.html'); + const $ = cheerioLoad(html); + + assert.equal($('h1').text(), 'Index'); + }); + + it('should render the index page when navigating /blog/hello ', async () => { + const html = await fixture.readFile('/blog/hello/index.html'); + const $ = cheerioLoad(html); + + assert.equal($('h1').text(), 'Index'); + }); + + it('should render the index page when navigating /blog/salut ', async () => { + const html = await fixture.readFile('/blog/salut/index.html'); + + const $ = cheerioLoad(html); + + assert.equal($('h1').text(), 'Index'); + }); + + it('should render the index page when navigating dynamic route /dynamic/[id] ', async () => { + const html = await fixture.readFile('/dynamic/hello/index.html'); + const $ = cheerioLoad(html); + + assert.equal($('h1').text(), 'Index'); + }); + + it('should render the index page when navigating spread route /spread/[...spread] ', async () => { + const html = await fixture.readFile('/spread/hello/index.html'); + const $ = cheerioLoad(html); + + assert.equal($('h1').text(), 'Index'); + }); + + it('should render the 404 built-in page', async () => { + try { + const html = await fixture.readFile('/spread/oops/index.html'); + assert.fail('Not found'); + } catch { + assert.ok; + } + }); +}); + +describe('SSR reroute', () => { + /** @type {import('./test-utils').Fixture} */ + let fixture; + let app; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/reroute/', + output: 'server', + adapter: testAdapter(), + }); + await fixture.build(); + app = await fixture.loadTestAdapterApp(); + }); + + it('should render the index page when navigating /reroute ', async () => { + const request = new Request('http://example.com/reroute'); + const response = await app.render(request); + const html = await response.text(); + const $ = cheerioLoad(html); + + assert.equal($('h1').text(), 'Index'); + }); + + it('should render the index page when navigating /blog/hello ', async () => { + const request = new Request('http://example.com/blog/hello'); + const response = await app.render(request); + const html = await response.text(); + const $ = cheerioLoad(html); + + assert.equal($('h1').text(), 'Index'); + }); + + it('should render the index page when navigating /blog/salut ', async () => { + const request = new Request('http://example.com/blog/salut'); + const response = await app.render(request); + const html = await response.text(); + + const $ = cheerioLoad(html); + + assert.equal($('h1').text(), 'Index'); + }); + + it('should render the index page when navigating dynamic route /dynamic/[id] ', async () => { + const request = new Request('http://example.com/dynamic/hello'); + const response = await app.render(request); + const html = await response.text(); + const $ = cheerioLoad(html); + + assert.equal($('h1').text(), 'Index'); + }); + + it('should render the index page when navigating spread route /spread/[...spread] ', async () => { + const request = new Request('http://example.com/spread/hello'); + const response = await app.render(request); + const html = await response.text(); + const $ = cheerioLoad(html); + + assert.equal($('h1').text(), 'Index'); + }); + + it('should render the 404 built-in page', async () => { + const request = new Request('http://example.com/blog/oops'); + const response = await app.render(request); + const html = await response.text(); + assert.equal(html, 'Not found'); + }); +}); + +describe('Middleware', () => { + /** @type {import('./test-utils').Fixture} */ + let fixture; + let devServer; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/reroute/', + }); + devServer = await fixture.startDevServer(); + }); + + after(async () => { + await devServer.stop(); + }); + + it('should render a locals populated in the third middleware function, because we use next("/")', async () => { + const html = await fixture.fetch('/auth/base').then((res) => res.text()); + const $ = cheerioLoad(html); + + assert.equal($('h1').text(), 'Index'); + assert.equal($('p').text(), 'Called auth'); + }); + + it('should NOT render locals populated in the third middleware function, because we use ctx.reroute("/")', async () => { + const html = await fixture.fetch('/auth/dashboard').then((res) => res.text()); + const $ = cheerioLoad(html); + + assert.equal($('h1').text(), 'Index'); + assert.equal($('p').text(), ''); + }); +}); diff --git a/packages/astro/test/units/routing/route-matching.test.js b/packages/astro/test/units/routing/route-matching.test.js index b2f27d8c9f80..5eafa6c80aea 100644 --- a/packages/astro/test/units/routing/route-matching.test.js +++ b/packages/astro/test/units/routing/route-matching.test.js @@ -146,7 +146,7 @@ describe('Route matching', () => { const loader = createViteLoader(container.viteServer); const manifest = createDevelopmentManifest(container.settings); - pipeline = DevPipeline.create({ loader, logger: defaultLogger, manifest, settings }); + pipeline = DevPipeline.create(undefined, { loader, logger: defaultLogger, manifest, settings }); manifestData = createRouteManifest( { cwd: fileURLToPath(root), diff --git a/packages/astro/test/units/vite-plugin-astro-server/request.test.js b/packages/astro/test/units/vite-plugin-astro-server/request.test.js index 7ea587f97e2f..f976a9d30b50 100644 --- a/packages/astro/test/units/vite-plugin-astro-server/request.test.js +++ b/packages/astro/test/units/vite-plugin-astro-server/request.test.js @@ -22,7 +22,7 @@ async function createDevPipeline(overrides = {}) { const loader = overrides.loader ?? createLoader(); const manifest = createDevelopmentManifest(settings); - return DevPipeline.create({ loader, logger: defaultLogger, manifest, settings }); + return DevPipeline.create(undefined, { loader, logger: defaultLogger, manifest, settings }); } describe('vite-plugin-astro-server', () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index be362ddc2641..8a32dab848f9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3114,6 +3114,12 @@ importers: specifier: workspace:* version: link:../../.. + packages/astro/test/fixtures/middleware-virtual: + dependencies: + astro: + specifier: workspace:* + version: link:../../.. + packages/astro/test/fixtures/minification-html: dependencies: astro: @@ -3312,6 +3318,12 @@ importers: specifier: workspace:* version: link:../../.. + packages/astro/test/fixtures/reroute: + dependencies: + astro: + specifier: workspace:* + version: link:../../.. + packages/astro/test/fixtures/root-srcdir-css: dependencies: astro: