Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

SSR 404 and 500 routes in adapters #4018

Merged
merged 3 commits into from
Jul 22, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/happy-parrots-stare.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'astro': minor
'@astrojs/cloudflare': minor
'@astrojs/netlify': minor
'@astrojs/vercel': minor
---

Support for 404 and 500 pages in SSR
48 changes: 41 additions & 7 deletions packages/astro/src/core/app/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ export { deserializeManifest } from './common.js';
export const pagesVirtualModuleId = '@astrojs-pages-virtual-entry';
export const resolvedPagesVirtualModuleId = '\0' + pagesVirtualModuleId;

export interface MatchOptions {
matchNotFound?: boolean | undefined;
}

export class App {
#manifest: Manifest;
#manifestData: ManifestData;
Expand All @@ -46,17 +50,30 @@ export class App {
this.#routeCache = new RouteCache(this.#logging);
this.#streaming = streaming;
}
match(request: Request): RouteData | undefined {
match(request: Request, { matchNotFound = false }: MatchOptions = {}): RouteData | undefined {
const url = new URL(request.url);
// ignore requests matching public assets
if (this.#manifest.assets.has(url.pathname)) {
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> {
let defaultStatus = 200;
if (!routeData) {
routeData = this.match(request);
if (!routeData) {
defaultStatus = 404;
routeData = this.match(request, { matchNotFound: true });
}
if (!routeData) {
return new Response(null, {
status: 404,
Expand All @@ -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') {
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') {
return this.#callEndpoint(request, routeData, mod);
return this.#callEndpoint(request, routeData, mod, defaultStatus);
} else {
throw new Error(`Unsupported route type [${routeData.type}].`);
}
Expand All @@ -79,7 +109,8 @@ export class App {
async #renderPage(
request: Request,
routeData: RouteData,
mod: ComponentInstance
mod: ComponentInstance,
status = 200
): Promise<Response> {
const url = new URL(request.url);
const manifest = this.#manifest;
Expand Down Expand Up @@ -128,6 +159,7 @@ export class App {
ssr: true,
request,
streaming: this.#streaming,
status
});

return response;
Expand All @@ -143,7 +175,8 @@ export class App {
async #callEndpoint(
request: Request,
routeData: RouteData,
mod: ComponentInstance
mod: ComponentInstance,
status = 200
): Promise<Response> {
const url = new URL(request.url);
const handler = mod as unknown as EndpointHandler;
Expand All @@ -155,6 +188,7 @@ export class App {
route: routeData,
routeCache: this.#routeCache,
ssr: true,
status
});

if (result.type === 'response') {
Expand Down
2 changes: 1 addition & 1 deletion packages/astro/src/core/endpoint/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { getParamsAndProps, GetParamsAndPropsError } from '../render/core.js';

export type EndpointOptions = Pick<
RenderOptions,
'logging' | 'origin' | 'request' | 'route' | 'routeCache' | 'pathname' | 'route' | 'site' | 'ssr'
'logging' | 'origin' | 'request' | 'route' | 'routeCache' | 'pathname' | 'route' | 'site' | 'ssr' | 'status'
>;

type EndpointCallResult =
Expand Down
3 changes: 3 additions & 0 deletions packages/astro/src/core/render/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ export interface RenderOptions {
ssr: boolean;
streaming: boolean;
request: Request;
status?: number;
}

export async function render(opts: RenderOptions): Promise<Response> {
Expand All @@ -107,6 +108,7 @@ export async function render(opts: RenderOptions): Promise<Response> {
site,
ssr,
streaming,
status = 200
} = opts;

const paramsAndPropsRes = await getParamsAndProps({
Expand Down Expand Up @@ -148,6 +150,7 @@ export async function render(opts: RenderOptions): Promise<Response> {
scripts,
ssr,
streaming,
status
});

// Support `export const components` for `MDX` pages
Expand Down
3 changes: 2 additions & 1 deletion packages/astro/src/core/render/result.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export interface CreateResultArgs {
scripts?: Set<SSRElement>;
styles?: Set<SSRElement>;
request: Request;
status: number;
}

function getFunctionExpression(slot: any) {
Expand Down Expand Up @@ -119,7 +120,7 @@ export function createResult(args: CreateResultArgs): SSRResult {
headers.set('Content-Type', 'text/html');
}
const response: ResponseInit = {
status: 200,
status: args.status,
statusText: 'OK',
headers,
};
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"name": "@test/ssr-api-route-custom-404",
"version": "0.0.0",
"private": true,
"dependencies": {
"astro": "workspace:*"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<h1>Something went horribly wrong!</h1>
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<h1>This is an error page</h1>
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
---
throw new Error(`oops`);
---
40 changes: 40 additions & 0 deletions packages/astro/test/ssr-404-500-pages.test.js
Original file line number Diff line number Diff line change
@@ -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');
});
});
11 changes: 3 additions & 8 deletions packages/integrations/cloudflare/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,19 +19,14 @@ export function createExports(manifest: SSRManifest) {
return env.ASSETS.fetch(assetRequest);
}

if (app.match(request)) {
let routeData = app.match(request, { matchNotFound: true });
if (routeData) {
Reflect.set(
request,
Symbol.for('astro.clientAddress'),
request.headers.get('cf-connecting-ip')
);
return app.render(request);
}

// 404
const _404Request = new Request(`${origin}/404`, request);
if (app.match(_404Request)) {
return app.render(_404Request);
return app.render(request, routeData);
}

return new Response(null, {
Expand Down
6 changes: 4 additions & 2 deletions packages/integrations/netlify/src/netlify-functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,9 @@ export const createExports = (manifest: SSRManifest, args: Args) => {
}
const request = new Request(rawUrl, init);

if (!app.match(request)) {
let routeData = app.match(request, { matchNotFound: true });

if (!routeData) {
return {
statusCode: 404,
body: 'Not found',
Expand All @@ -76,7 +78,7 @@ export const createExports = (manifest: SSRManifest, args: Args) => {
const ip = headers['x-nf-client-connection-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 responseContentType = parseContentType(responseHeaders['content-type']);
Expand Down
5 changes: 3 additions & 2 deletions packages/integrations/vercel/src/serverless/entrypoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,13 @@ export const createExports = (manifest: SSRManifest) => {
return res.end(err.reason || 'Invalid request body');
}

if (!app.match(request)) {
let routeData = app.match(request, { matchNotFound: true });
if (!routeData) {
res.statusCode = 404;
return res.end('Not found');
}

await setResponse(res, await app.render(request));
await setResponse(res, await app.render(request, routeData));
};

return { default: handler };
Expand Down
6 changes: 6 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.