mirror of
https://github.com/withastro/astro.git
synced 2025-01-06 22:10:10 -05:00
feat: middleware and virtual routes (#10206)
* add test * app * dev * api route -> page * adjust test * add changeset * remove `any` * DEFAULT_404_COMPONENT constant * Apply suggestions from code review * Apply suggestions from code review Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca> --------- Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>
This commit is contained in:
parent
8107a2721b
commit
dc87214141
18 changed files with 142 additions and 15 deletions
7
.changeset/brown-pets-clean.md
Normal file
7
.changeset/brown-pets-clean.md
Normal file
|
@ -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.
|
|
@ -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<SinglePageBuiltModule> {
|
||||
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 {
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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<Props> {
|
|||
return {};
|
||||
}
|
||||
|
||||
if (routeIsRedirect(route) || routeIsFallback(route)) {
|
||||
if (routeIsRedirect(route) || routeIsFallback(route) || route.component === DEFAULT_404_COMPONENT) {
|
||||
return {};
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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<AstroFactoryReturnValue>;
|
||||
isAstroComponentFactory?: boolean;
|
||||
moduleId?: string | undefined;
|
||||
propagation?: PropagationHint;
|
||||
|
|
|
@ -57,7 +57,7 @@ export class AstroComponentInstance {
|
|||
await this.init(this.result);
|
||||
}
|
||||
|
||||
let value: AstroFactoryReturnValue | undefined = this.returnValue;
|
||||
let value: Promise<AstroFactoryReturnValue> | AstroFactoryReturnValue | undefined = this.returnValue;
|
||||
if (isPromise(value)) {
|
||||
value = await value;
|
||||
}
|
||||
|
|
|
@ -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));
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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<T extends (...args: any) => Promise<any>> = 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);
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
6
packages/astro/test/fixtures/virtual-routes/astro.config.js
vendored
Normal file
6
packages/astro/test/fixtures/virtual-routes/astro.config.js
vendored
Normal file
|
@ -0,0 +1,6 @@
|
|||
import testAdapter from '../../test-adapter.js';
|
||||
|
||||
export default {
|
||||
output: 'server',
|
||||
adapter: testAdapter(),
|
||||
};
|
7
packages/astro/test/fixtures/virtual-routes/package.json
vendored
Normal file
7
packages/astro/test/fixtures/virtual-routes/package.json
vendored
Normal file
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"name": "@test/virtual-routes",
|
||||
"dependencies": {
|
||||
"astro": "workspace:*"
|
||||
}
|
||||
}
|
||||
|
8
packages/astro/test/fixtures/virtual-routes/src/middleware.js
vendored
Normal file
8
packages/astro/test/fixtures/virtual-routes/src/middleware.js
vendored
Normal file
|
@ -0,0 +1,8 @@
|
|||
export function onRequest (context, next) {
|
||||
if (context.request.url.includes('/virtual')) {
|
||||
return new Response('<span>Virtual!!</span>', {
|
||||
status: 200,
|
||||
});
|
||||
}
|
||||
return next()
|
||||
}
|
|
@ -54,7 +54,8 @@ describe('trailingSlash', () => {
|
|||
url: '/api',
|
||||
});
|
||||
container.handle(req, res);
|
||||
assert.equal(await text(), '');
|
||||
const html = await text();
|
||||
assert.equal(html.includes(`<span class="statusMessage">Not found</span>`), true);
|
||||
assert.equal(res.statusCode, 404);
|
||||
});
|
||||
});
|
||||
|
|
30
packages/astro/test/virtual-routes.test.js
Normal file
30
packages/astro/test/virtual-routes.test.js
Normal file
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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':
|
||||
|
|
Loading…
Reference in a new issue