SSR 404 and 500 routes in adapters (#4018)
* fix(WIP): SSR 404 and 500 routes * Implement the feature Co-authored-by: Matthew Phillips <matthew@skypack.dev>
This commit is contained in:
parent
4392083cca
commit
0cc6ede362
14 changed files with 124 additions and 21 deletions
8
.changeset/happy-parrots-stare.md
Normal file
8
.changeset/happy-parrots-stare.md
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
---
|
||||||
|
'astro': minor
|
||||||
|
'@astrojs/cloudflare': minor
|
||||||
|
'@astrojs/netlify': minor
|
||||||
|
'@astrojs/vercel': minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Support for 404 and 500 pages in SSR
|
|
@ -25,6 +25,10 @@ export { deserializeManifest } from './common.js';
|
||||||
export const pagesVirtualModuleId = '@astrojs-pages-virtual-entry';
|
export const pagesVirtualModuleId = '@astrojs-pages-virtual-entry';
|
||||||
export const resolvedPagesVirtualModuleId = '\0' + pagesVirtualModuleId;
|
export const resolvedPagesVirtualModuleId = '\0' + pagesVirtualModuleId;
|
||||||
|
|
||||||
|
export interface MatchOptions {
|
||||||
|
matchNotFound?: boolean | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export class App {
|
export class App {
|
||||||
#manifest: Manifest;
|
#manifest: Manifest;
|
||||||
#manifestData: ManifestData;
|
#manifestData: ManifestData;
|
||||||
|
@ -46,17 +50,30 @@ export class App {
|
||||||
this.#routeCache = new RouteCache(this.#logging);
|
this.#routeCache = new RouteCache(this.#logging);
|
||||||
this.#streaming = streaming;
|
this.#streaming = streaming;
|
||||||
}
|
}
|
||||||
match(request: Request): RouteData | undefined {
|
match(request: Request, { matchNotFound = false }: MatchOptions = {}): RouteData | undefined {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
// ignore requests matching public assets
|
// ignore requests matching public assets
|
||||||
if (this.#manifest.assets.has(url.pathname)) {
|
if (this.#manifest.assets.has(url.pathname)) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
return matchRoute(url.pathname, this.#manifestData);
|
let routeData = matchRoute(url.pathname, this.#manifestData);
|
||||||
|
|
||||||
|
if(routeData) {
|
||||||
|
return routeData;
|
||||||
|
} else if(matchNotFound) {
|
||||||
|
return matchRoute('/404', this.#manifestData);
|
||||||
|
} else {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
async render(request: Request, routeData?: RouteData): Promise<Response> {
|
async render(request: Request, routeData?: RouteData): Promise<Response> {
|
||||||
|
let defaultStatus = 200;
|
||||||
if (!routeData) {
|
if (!routeData) {
|
||||||
routeData = this.match(request);
|
routeData = this.match(request);
|
||||||
|
if (!routeData) {
|
||||||
|
defaultStatus = 404;
|
||||||
|
routeData = this.match(request, { matchNotFound: true });
|
||||||
|
}
|
||||||
if (!routeData) {
|
if (!routeData) {
|
||||||
return new Response(null, {
|
return new Response(null, {
|
||||||
status: 404,
|
status: 404,
|
||||||
|
@ -65,12 +82,25 @@ export class App {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const mod = this.#manifest.pageMap.get(routeData.component)!;
|
let mod = this.#manifest.pageMap.get(routeData.component)!;
|
||||||
|
|
||||||
if (routeData.type === 'page') {
|
if (routeData.type === 'page') {
|
||||||
return this.#renderPage(request, routeData, mod);
|
let response = await this.#renderPage(request, routeData, mod, defaultStatus);
|
||||||
|
|
||||||
|
// If there was a 500 error, try sending the 500 page.
|
||||||
|
if(response.status === 500) {
|
||||||
|
const fiveHundredRouteData = matchRoute('/500', this.#manifestData);
|
||||||
|
if(fiveHundredRouteData) {
|
||||||
|
mod = this.#manifest.pageMap.get(fiveHundredRouteData.component)!;
|
||||||
|
try {
|
||||||
|
let fiveHundredResponse = await this.#renderPage(request, fiveHundredRouteData, mod, 500);
|
||||||
|
return fiveHundredResponse;
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return response;
|
||||||
} else if (routeData.type === 'endpoint') {
|
} else if (routeData.type === 'endpoint') {
|
||||||
return this.#callEndpoint(request, routeData, mod);
|
return this.#callEndpoint(request, routeData, mod, defaultStatus);
|
||||||
} else {
|
} else {
|
||||||
throw new Error(`Unsupported route type [${routeData.type}].`);
|
throw new Error(`Unsupported route type [${routeData.type}].`);
|
||||||
}
|
}
|
||||||
|
@ -79,7 +109,8 @@ export class App {
|
||||||
async #renderPage(
|
async #renderPage(
|
||||||
request: Request,
|
request: Request,
|
||||||
routeData: RouteData,
|
routeData: RouteData,
|
||||||
mod: ComponentInstance
|
mod: ComponentInstance,
|
||||||
|
status = 200
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const manifest = this.#manifest;
|
const manifest = this.#manifest;
|
||||||
|
@ -128,6 +159,7 @@ export class App {
|
||||||
ssr: true,
|
ssr: true,
|
||||||
request,
|
request,
|
||||||
streaming: this.#streaming,
|
streaming: this.#streaming,
|
||||||
|
status
|
||||||
});
|
});
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
|
@ -143,7 +175,8 @@ export class App {
|
||||||
async #callEndpoint(
|
async #callEndpoint(
|
||||||
request: Request,
|
request: Request,
|
||||||
routeData: RouteData,
|
routeData: RouteData,
|
||||||
mod: ComponentInstance
|
mod: ComponentInstance,
|
||||||
|
status = 200
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const handler = mod as unknown as EndpointHandler;
|
const handler = mod as unknown as EndpointHandler;
|
||||||
|
@ -155,6 +188,7 @@ export class App {
|
||||||
route: routeData,
|
route: routeData,
|
||||||
routeCache: this.#routeCache,
|
routeCache: this.#routeCache,
|
||||||
ssr: true,
|
ssr: true,
|
||||||
|
status
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.type === 'response') {
|
if (result.type === 'response') {
|
||||||
|
|
|
@ -5,7 +5,7 @@ import { getParamsAndProps, GetParamsAndPropsError } from '../render/core.js';
|
||||||
|
|
||||||
export type EndpointOptions = Pick<
|
export type EndpointOptions = Pick<
|
||||||
RenderOptions,
|
RenderOptions,
|
||||||
'logging' | 'origin' | 'request' | 'route' | 'routeCache' | 'pathname' | 'route' | 'site' | 'ssr'
|
'logging' | 'origin' | 'request' | 'route' | 'routeCache' | 'pathname' | 'route' | 'site' | 'ssr' | 'status'
|
||||||
>;
|
>;
|
||||||
|
|
||||||
type EndpointCallResult =
|
type EndpointCallResult =
|
||||||
|
|
|
@ -85,6 +85,7 @@ export interface RenderOptions {
|
||||||
ssr: boolean;
|
ssr: boolean;
|
||||||
streaming: boolean;
|
streaming: boolean;
|
||||||
request: Request;
|
request: Request;
|
||||||
|
status?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function render(opts: RenderOptions): Promise<Response> {
|
export async function render(opts: RenderOptions): Promise<Response> {
|
||||||
|
@ -107,6 +108,7 @@ export async function render(opts: RenderOptions): Promise<Response> {
|
||||||
site,
|
site,
|
||||||
ssr,
|
ssr,
|
||||||
streaming,
|
streaming,
|
||||||
|
status = 200
|
||||||
} = opts;
|
} = opts;
|
||||||
|
|
||||||
const paramsAndPropsRes = await getParamsAndProps({
|
const paramsAndPropsRes = await getParamsAndProps({
|
||||||
|
@ -148,6 +150,7 @@ export async function render(opts: RenderOptions): Promise<Response> {
|
||||||
scripts,
|
scripts,
|
||||||
ssr,
|
ssr,
|
||||||
streaming,
|
streaming,
|
||||||
|
status
|
||||||
});
|
});
|
||||||
|
|
||||||
// Support `export const components` for `MDX` pages
|
// Support `export const components` for `MDX` pages
|
||||||
|
|
|
@ -42,6 +42,7 @@ export interface CreateResultArgs {
|
||||||
scripts?: Set<SSRElement>;
|
scripts?: Set<SSRElement>;
|
||||||
styles?: Set<SSRElement>;
|
styles?: Set<SSRElement>;
|
||||||
request: Request;
|
request: Request;
|
||||||
|
status: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getFunctionExpression(slot: any) {
|
function getFunctionExpression(slot: any) {
|
||||||
|
@ -119,7 +120,7 @@ export function createResult(args: CreateResultArgs): SSRResult {
|
||||||
headers.set('Content-Type', 'text/html');
|
headers.set('Content-Type', 'text/html');
|
||||||
}
|
}
|
||||||
const response: ResponseInit = {
|
const response: ResponseInit = {
|
||||||
status: 200,
|
status: args.status,
|
||||||
statusText: 'OK',
|
statusText: 'OK',
|
||||||
headers,
|
headers,
|
||||||
};
|
};
|
||||||
|
|
8
packages/astro/test/fixtures/ssr-api-route-custom-404/package.json
vendored
Normal file
8
packages/astro/test/fixtures/ssr-api-route-custom-404/package.json
vendored
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"name": "@test/ssr-api-route-custom-404",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"dependencies": {
|
||||||
|
"astro": "workspace:*"
|
||||||
|
}
|
||||||
|
}
|
1
packages/astro/test/fixtures/ssr-api-route-custom-404/src/pages/404.astro
vendored
Normal file
1
packages/astro/test/fixtures/ssr-api-route-custom-404/src/pages/404.astro
vendored
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<h1>Something went horribly wrong!</h1>
|
1
packages/astro/test/fixtures/ssr-api-route-custom-404/src/pages/500.astro
vendored
Normal file
1
packages/astro/test/fixtures/ssr-api-route-custom-404/src/pages/500.astro
vendored
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<h1>This is an error page</h1>
|
3
packages/astro/test/fixtures/ssr-api-route-custom-404/src/pages/causes-error.astro
vendored
Normal file
3
packages/astro/test/fixtures/ssr-api-route-custom-404/src/pages/causes-error.astro
vendored
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
---
|
||||||
|
throw new Error(`oops`);
|
||||||
|
---
|
40
packages/astro/test/ssr-404-500-pages.test.js
Normal file
40
packages/astro/test/ssr-404-500-pages.test.js
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
import { expect } from 'chai';
|
||||||
|
import { loadFixture } from './test-utils.js';
|
||||||
|
import testAdapter from './test-adapter.js';
|
||||||
|
import * as cheerio from 'cheerio';
|
||||||
|
|
||||||
|
describe('404 and 500 pages', () => {
|
||||||
|
/** @type {import('./test-utils').Fixture} */
|
||||||
|
let fixture;
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
fixture = await loadFixture({
|
||||||
|
root: './fixtures/ssr-api-route-custom-404/',
|
||||||
|
experimental: {
|
||||||
|
ssr: true,
|
||||||
|
},
|
||||||
|
adapter: testAdapter(),
|
||||||
|
});
|
||||||
|
await fixture.build({ });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('404 page returned when a route does not match', async () => {
|
||||||
|
const app = await fixture.loadTestAdapterApp();
|
||||||
|
const request = new Request('http://example.com/some/fake/route');
|
||||||
|
const response = await app.render(request);
|
||||||
|
expect(response.status).to.equal(404);
|
||||||
|
const html = await response.text();
|
||||||
|
const $ = cheerio.load(html);
|
||||||
|
expect($('h1').text()).to.equal('Something went horribly wrong!');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('500 page returned when there is an error', async () => {
|
||||||
|
const app = await fixture.loadTestAdapterApp();
|
||||||
|
const request = new Request('http://example.com/causes-error');
|
||||||
|
const response = await app.render(request);
|
||||||
|
expect(response.status).to.equal(500);
|
||||||
|
const html = await response.text();
|
||||||
|
const $ = cheerio.load(html);
|
||||||
|
expect($('h1').text()).to.equal('This is an error page');
|
||||||
|
});
|
||||||
|
});
|
|
@ -19,19 +19,14 @@ export function createExports(manifest: SSRManifest) {
|
||||||
return env.ASSETS.fetch(assetRequest);
|
return env.ASSETS.fetch(assetRequest);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (app.match(request)) {
|
let routeData = app.match(request, { matchNotFound: true });
|
||||||
|
if (routeData) {
|
||||||
Reflect.set(
|
Reflect.set(
|
||||||
request,
|
request,
|
||||||
Symbol.for('astro.clientAddress'),
|
Symbol.for('astro.clientAddress'),
|
||||||
request.headers.get('cf-connecting-ip')
|
request.headers.get('cf-connecting-ip')
|
||||||
);
|
);
|
||||||
return app.render(request);
|
return app.render(request, routeData);
|
||||||
}
|
|
||||||
|
|
||||||
// 404
|
|
||||||
const _404Request = new Request(`${origin}/404`, request);
|
|
||||||
if (app.match(_404Request)) {
|
|
||||||
return app.render(_404Request);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Response(null, {
|
return new Response(null, {
|
||||||
|
|
|
@ -66,7 +66,9 @@ export const createExports = (manifest: SSRManifest, args: Args) => {
|
||||||
}
|
}
|
||||||
const request = new Request(rawUrl, init);
|
const request = new Request(rawUrl, init);
|
||||||
|
|
||||||
if (!app.match(request)) {
|
let routeData = app.match(request, { matchNotFound: true });
|
||||||
|
|
||||||
|
if (!routeData) {
|
||||||
return {
|
return {
|
||||||
statusCode: 404,
|
statusCode: 404,
|
||||||
body: 'Not found',
|
body: 'Not found',
|
||||||
|
@ -76,7 +78,7 @@ export const createExports = (manifest: SSRManifest, args: Args) => {
|
||||||
const ip = headers['x-nf-client-connection-ip'];
|
const ip = headers['x-nf-client-connection-ip'];
|
||||||
Reflect.set(request, clientAddressSymbol, ip);
|
Reflect.set(request, clientAddressSymbol, ip);
|
||||||
|
|
||||||
const response: Response = await app.render(request);
|
const response: Response = await app.render(request, routeData);
|
||||||
const responseHeaders = Object.fromEntries(response.headers.entries());
|
const responseHeaders = Object.fromEntries(response.headers.entries());
|
||||||
|
|
||||||
const responseContentType = parseContentType(responseHeaders['content-type']);
|
const responseContentType = parseContentType(responseHeaders['content-type']);
|
||||||
|
|
|
@ -22,12 +22,13 @@ export const createExports = (manifest: SSRManifest) => {
|
||||||
return res.end(err.reason || 'Invalid request body');
|
return res.end(err.reason || 'Invalid request body');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!app.match(request)) {
|
let routeData = app.match(request, { matchNotFound: true });
|
||||||
|
if (!routeData) {
|
||||||
res.statusCode = 404;
|
res.statusCode = 404;
|
||||||
return res.end('Not found');
|
return res.end('Not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
await setResponse(res, await app.render(request));
|
await setResponse(res, await app.render(request, routeData));
|
||||||
};
|
};
|
||||||
|
|
||||||
return { default: handler };
|
return { default: handler };
|
||||||
|
|
6
pnpm-lock.yaml
generated
6
pnpm-lock.yaml
generated
|
@ -1770,6 +1770,12 @@ importers:
|
||||||
dependencies:
|
dependencies:
|
||||||
astro: link:../../..
|
astro: link:../../..
|
||||||
|
|
||||||
|
packages/astro/test/fixtures/ssr-api-route-custom-404:
|
||||||
|
specifiers:
|
||||||
|
astro: workspace:*
|
||||||
|
dependencies:
|
||||||
|
astro: link:../../..
|
||||||
|
|
||||||
packages/astro/test/fixtures/ssr-assets:
|
packages/astro/test/fixtures/ssr-assets:
|
||||||
specifiers:
|
specifiers:
|
||||||
astro: workspace:*
|
astro: workspace:*
|
||||||
|
|
Loading…
Add table
Reference in a new issue