diff --git a/.changeset/brown-pets-clean.md b/.changeset/brown-pets-clean.md new file mode 100644 index 000000000000..28cc4e98f5f8 --- /dev/null +++ b/.changeset/brown-pets-clean.md @@ -0,0 +1,7 @@ +--- +"astro": minor +--- + +Allows middleware to run when a matching page or endpoint is not found. Previously, a `pages/404.astro` or `pages/[...catch-all].astro` route had to match to allow middleware. This is now not necessary. + +When a route does not match in SSR deployments, your adapter may show a platform-specific 404 page instead of running Astro's SSR code. In these cases, you may still need to add a `404.astro` or fallback route with spread params, or use a routing configuration option if your adapter provides one. diff --git a/packages/astro/src/core/app/index.ts b/packages/astro/src/core/app/index.ts index 7c8d0067afbc..bf0c61232f09 100644 --- a/packages/astro/src/core/app/index.ts +++ b/packages/astro/src/core/app/index.ts @@ -1,7 +1,8 @@ -import type { ManifestData, RouteData, SSRManifest } from '../../@types/astro.js'; import { normalizeTheLocale } from '../../i18n/index.js'; +import type { ComponentInstance, ManifestData, RouteData, SSRManifest } from '../../@types/astro.js'; import type { SinglePageBuiltModule } from '../build/types.js'; import { + DEFAULT_404_COMPONENT, REROUTABLE_STATUS_CODES, REROUTE_DIRECTIVE_HEADER, clientAddressSymbol, @@ -24,6 +25,7 @@ import { RenderContext } from '../render-context.js'; import { createAssetLink } from '../render/ssr-element.js'; import { matchRoute } from '../routing/match.js'; import { AppPipeline } from './pipeline.js'; +import { ensure404Route } from '../routing/astro-designed-error-pages.js'; export { deserializeManifest } from './common.js'; export interface RenderOptions { @@ -82,9 +84,9 @@ export class App { constructor(manifest: SSRManifest, streaming = true) { this.#manifest = manifest; - this.#manifestData = { + this.#manifestData = ensure404Route({ routes: manifest.routes.map((route) => route.routeData), - }; + }); this.#baseWithoutTrailingSlash = removeTrailingForwardSlash(this.#manifest.base); this.#pipeline = this.#createPipeline(streaming); this.#adapterLogger = new AstroIntegrationLogger( @@ -475,6 +477,12 @@ export class App { } 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 { diff --git a/packages/astro/src/core/constants.ts b/packages/astro/src/core/constants.ts index 1466ab86af49..aabdcbcab24f 100644 --- a/packages/astro/src/core/constants.ts +++ b/packages/astro/src/core/constants.ts @@ -4,6 +4,8 @@ export const ASTRO_VERSION = process.env.PACKAGE_VERSION ?? 'development'; export const REROUTE_DIRECTIVE_HEADER = 'X-Astro-Reroute'; export const ROUTE_TYPE_HEADER = 'X-Astro-Route-Type'; +export const DEFAULT_404_COMPONENT = 'astro-default-404'; + /** * A response with one of these status codes will be rewritten * with the result of rendering the respective error page. diff --git a/packages/astro/src/core/render/params-and-props.ts b/packages/astro/src/core/render/params-and-props.ts index b0a589ab1655..eeae1a9b45ab 100644 --- a/packages/astro/src/core/render/params-and-props.ts +++ b/packages/astro/src/core/render/params-and-props.ts @@ -1,4 +1,5 @@ import type { ComponentInstance, Params, Props, RouteData } from '../../@types/astro.js'; +import { DEFAULT_404_COMPONENT } from '../constants.js'; import { AstroError, AstroErrorData } from '../errors/index.js'; import type { Logger } from '../logger/core.js'; import { routeIsFallback } from '../redirects/helpers.js'; @@ -24,7 +25,7 @@ export async function getProps(opts: GetParamsAndPropsOptions): Promise { return {}; } - if (routeIsRedirect(route) || routeIsFallback(route)) { + if (routeIsRedirect(route) || routeIsFallback(route) || route.component === DEFAULT_404_COMPONENT) { return {}; } diff --git a/packages/astro/src/core/routing/astro-designed-error-pages.ts b/packages/astro/src/core/routing/astro-designed-error-pages.ts new file mode 100644 index 000000000000..ac2b08274074 --- /dev/null +++ b/packages/astro/src/core/routing/astro-designed-error-pages.ts @@ -0,0 +1,20 @@ +import type { ManifestData } from "../../@types/astro.js"; +import { DEFAULT_404_COMPONENT } from "../constants.js"; + +export function ensure404Route(manifest: ManifestData) { + if (!manifest.routes.some(route => route.route === '/404')) { + manifest.routes.push({ + component: DEFAULT_404_COMPONENT, + generate: () => '', + params: [], + pattern: /\/404/, + prerender: false, + segments: [], + type: 'page', + route: '/404', + fallbackRoutes: [], + isIndex: false, + }) + } + return manifest; +} diff --git a/packages/astro/src/runtime/server/render/astro/factory.ts b/packages/astro/src/runtime/server/render/astro/factory.ts index 68d5af73932b..f298a4ac1c61 100644 --- a/packages/astro/src/runtime/server/render/astro/factory.ts +++ b/packages/astro/src/runtime/server/render/astro/factory.ts @@ -6,7 +6,7 @@ export type AstroFactoryReturnValue = RenderTemplateResult | Response | HeadAndC // The callback passed to to $$createComponent export interface AstroComponentFactory { - (result: any, props: any, slots: any): AstroFactoryReturnValue; + (result: any, props: any, slots: any): AstroFactoryReturnValue | Promise; isAstroComponentFactory?: boolean; moduleId?: string | undefined; propagation?: PropagationHint; diff --git a/packages/astro/src/runtime/server/render/astro/instance.ts b/packages/astro/src/runtime/server/render/astro/instance.ts index 389ae71ba245..2119823c4fa4 100644 --- a/packages/astro/src/runtime/server/render/astro/instance.ts +++ b/packages/astro/src/runtime/server/render/astro/instance.ts @@ -57,7 +57,7 @@ export class AstroComponentInstance { await this.init(this.result); } - let value: AstroFactoryReturnValue | undefined = this.returnValue; + let value: Promise | AstroFactoryReturnValue | undefined = this.returnValue; if (isPromise(value)) { value = await value; } diff --git a/packages/astro/src/vite-plugin-astro-server/pipeline.ts b/packages/astro/src/vite-plugin-astro-server/pipeline.ts index 36fee4e131b9..157f0c603db8 100644 --- a/packages/astro/src/vite-plugin-astro-server/pipeline.ts +++ b/packages/astro/src/vite-plugin-astro-server/pipeline.ts @@ -10,7 +10,7 @@ import type { } from '../@types/astro.js'; import { getInfoOutput } from '../cli/info/index.js'; import type { HeadElements } from '../core/base-pipeline.js'; -import { ASTRO_VERSION } from '../core/constants.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 type { Logger } from '../core/logger/core.js'; @@ -23,6 +23,7 @@ import { getStylesForURL } from './css.js'; import { getComponentMetadata } from './metadata.js'; import { createResolve } from './resolve.js'; import { getScriptsForURL } from './scripts.js'; +import { default404Page } from './response.js'; export class DevPipeline extends Pipeline { // renderers are loaded on every request, @@ -136,6 +137,9 @@ export class DevPipeline extends Pipeline { async preload(filePath: URL) { const { loader } = this; + if (filePath.href === new URL(DEFAULT_404_COMPONENT, this.config.root).href) { + return { default: default404Page } as any as ComponentInstance + } // Important: This needs to happen first, in case a renderer provides polyfills. const renderers__ = this.settings.renderers.map((r) => loadRenderer(r, loader)); diff --git a/packages/astro/src/vite-plugin-astro-server/plugin.ts b/packages/astro/src/vite-plugin-astro-server/plugin.ts index c1f6aa42e5b9..be0f6a8ed92b 100644 --- a/packages/astro/src/vite-plugin-astro-server/plugin.ts +++ b/packages/astro/src/vite-plugin-astro-server/plugin.ts @@ -17,6 +17,7 @@ import { recordServerError } from './error.js'; import { DevPipeline } from './pipeline.js'; import { handleRequest } from './request.js'; import { setRouteError } from './server-state.js'; +import { ensure404Route } from '../core/routing/astro-designed-error-pages.js'; export interface AstroPluginOptions { settings: AstroSettings; @@ -35,15 +36,15 @@ export default function createVitePluginAstroServer({ const loader = createViteLoader(viteServer); const manifest = createDevelopmentManifest(settings); const pipeline = DevPipeline.create({ loader, logger, manifest, settings }); - let manifestData: ManifestData = createRouteManifest({ settings, fsMod }, logger); + let manifestData: ManifestData = ensure404Route(createRouteManifest({ settings, fsMod }, logger)); const controller = createController({ loader }); const localStorage = new AsyncLocalStorage(); - + /** rebuild the route cache + manifest, as needed. */ function rebuildManifest(needsManifestRebuild: boolean) { pipeline.clearRouteCache(); if (needsManifestRebuild) { - manifestData = createRouteManifest({ settings }, logger); + manifestData = ensure404Route(createRouteManifest({ settings }, logger)); } } // Rebuild route manifest on file change, if needed. diff --git a/packages/astro/src/vite-plugin-astro-server/response.ts b/packages/astro/src/vite-plugin-astro-server/response.ts index c6e034aefcd5..6dccc753f00e 100644 --- a/packages/astro/src/vite-plugin-astro-server/response.ts +++ b/packages/astro/src/vite-plugin-astro-server/response.ts @@ -23,6 +23,19 @@ export async function handle404Response( writeHtmlResponse(res, 404, html); } +export async function default404Page( + { pathname }: { pathname: string } +) { + return new Response(notFoundTemplate({ + statusCode: 404, + title: 'Not found', + tabTitle: '404: Not Found', + pathname, + }), { status: 404, headers: { 'Content-Type': 'text/html; charset=utf-8' } }); +} +// mark the function as an AstroComponentFactory for the rendering internals +default404Page.isAstroComponentFactory = true; + export async function handle500Response( loader: ModuleLoader, res: http.ServerResponse, diff --git a/packages/astro/src/vite-plugin-astro-server/route.ts b/packages/astro/src/vite-plugin-astro-server/route.ts index 1c533dbc8e7d..a38130dcad17 100644 --- a/packages/astro/src/vite-plugin-astro-server/route.ts +++ b/packages/astro/src/vite-plugin-astro-server/route.ts @@ -1,6 +1,6 @@ import type http from 'node:http'; import type { ComponentInstance, ManifestData, RouteData } from '../@types/astro.js'; -import { REROUTE_DIRECTIVE_HEADER, clientLocalsSymbol } from '../core/constants.js'; +import { DEFAULT_404_COMPONENT, REROUTE_DIRECTIVE_HEADER, clientLocalsSymbol } from '../core/constants.js'; import { AstroErrorData, isAstroError } from '../core/errors/index.js'; import { req } from '../core/messages.js'; import { loadMiddleware } from '../core/middleware/loadMiddleware.js'; @@ -11,7 +11,7 @@ import { matchAllRoutes } from '../core/routing/index.js'; import { normalizeTheLocale } from '../i18n/index.js'; import { getSortedPreloadedMatches } from '../prerender/routing.js'; import type { DevPipeline } from './pipeline.js'; -import { handle404Response, writeSSRResult, writeWebResponse } from './response.js'; +import { default404Page, handle404Response, writeSSRResult, writeWebResponse } from './response.js'; type AsyncReturnType Promise> = T extends ( ...args: any @@ -94,6 +94,19 @@ export async function matchRoute( } const custom404 = getCustom404Route(manifestData); + + if (custom404 && custom404.component === DEFAULT_404_COMPONENT) { + const component: ComponentInstance = { + default: default404Page + } + return { + route: custom404, + filePath: new URL(`file://${custom404.component}`), + resolvedPathname: pathname, + preloadedComponent: component, + mod: component, + } + } if (custom404) { const filePath = new URL(`./${custom404.component}`, config.root); diff --git a/packages/astro/test/astro-dev-headers.test.js b/packages/astro/test/astro-dev-headers.test.js index e119e365a9d7..ec7999c33d0c 100644 --- a/packages/astro/test/astro-dev-headers.test.js +++ b/packages/astro/test/astro-dev-headers.test.js @@ -31,10 +31,10 @@ describe('Astro dev headers', () => { assert.equal(Object.fromEntries(result.headers)['x-astro'], headers['x-astro']); }); - it('does not return custom headers for invalid URLs', async () => { + it('returns custom headers in the default 404 response', async () => { const result = await fixture.fetch('/bad-url'); assert.equal(result.status, 404); - assert.equal(Object.fromEntries(result.headers).hasOwnProperty('x-astro'), false); + assert.equal(Object.fromEntries(result.headers).hasOwnProperty('x-astro'), true); }); }); }); diff --git a/packages/astro/test/fixtures/virtual-routes/astro.config.js b/packages/astro/test/fixtures/virtual-routes/astro.config.js new file mode 100644 index 000000000000..37a31e918d05 --- /dev/null +++ b/packages/astro/test/fixtures/virtual-routes/astro.config.js @@ -0,0 +1,6 @@ +import testAdapter from '../../test-adapter.js'; + +export default { + output: 'server', + adapter: testAdapter(), +}; diff --git a/packages/astro/test/fixtures/virtual-routes/package.json b/packages/astro/test/fixtures/virtual-routes/package.json new file mode 100644 index 000000000000..1e11618c71e6 --- /dev/null +++ b/packages/astro/test/fixtures/virtual-routes/package.json @@ -0,0 +1,7 @@ +{ + "name": "@test/virtual-routes", + "dependencies": { + "astro": "workspace:*" + } + } + \ No newline at end of file diff --git a/packages/astro/test/fixtures/virtual-routes/src/middleware.js b/packages/astro/test/fixtures/virtual-routes/src/middleware.js new file mode 100644 index 000000000000..96b626601e7a --- /dev/null +++ b/packages/astro/test/fixtures/virtual-routes/src/middleware.js @@ -0,0 +1,8 @@ +export function onRequest (context, next) { + if (context.request.url.includes('/virtual')) { + return new Response('Virtual!!', { + status: 200, + }); + } + return next() +} diff --git a/packages/astro/test/units/routing/trailing-slash.test.js b/packages/astro/test/units/routing/trailing-slash.test.js index 8bbc33f199fb..a9e8fe9451dd 100644 --- a/packages/astro/test/units/routing/trailing-slash.test.js +++ b/packages/astro/test/units/routing/trailing-slash.test.js @@ -54,7 +54,8 @@ describe('trailingSlash', () => { url: '/api', }); container.handle(req, res); - assert.equal(await text(), ''); + const html = await text(); + assert.equal(html.includes(`Not found`), true); assert.equal(res.statusCode, 404); }); }); diff --git a/packages/astro/test/virtual-routes.test.js b/packages/astro/test/virtual-routes.test.js new file mode 100644 index 000000000000..2c9286e8e280 --- /dev/null +++ b/packages/astro/test/virtual-routes.test.js @@ -0,0 +1,30 @@ +import assert from 'node:assert/strict'; +import { before, describe, it } from 'node:test'; +import { loadFixture } from './test-utils.js'; + +describe('virtual routes - dev', () => { + /** @type {import('./test-utils').Fixture} */ + let fixture; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/virtual-routes/', + }); + await fixture.build(); + }); + + it('should render a virtual route - dev', async () => { + const devServer = await fixture.startDevServer(); + const response = await fixture.fetch('/virtual'); + const html = await response.text(); + assert.equal(html.includes('Virtual!!'), true); + await devServer.stop(); + }); + + it('should render a virtual route - app', async () => { + const app = await fixture.loadTestAdapterApp(); + const response = await app.render(new Request('https://example.com/virtual')); + const html = await response.text(); + assert.equal(html.includes('Virtual!!'), true); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 950710d7a2d9..f9f8f4904b86 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3731,6 +3731,12 @@ importers: specifier: workspace:* version: link:../../.. + packages/astro/test/fixtures/virtual-routes: + dependencies: + astro: + specifier: workspace:* + version: link:../../.. + packages/astro/test/fixtures/vue-component: dependencies: '@astrojs/vue':