diff --git a/.changeset/happy-parrots-stare.md b/.changeset/happy-parrots-stare.md new file mode 100644 index 000000000000..54d117320b7f --- /dev/null +++ b/.changeset/happy-parrots-stare.md @@ -0,0 +1,8 @@ +--- +'astro': minor +'@astrojs/cloudflare': minor +'@astrojs/netlify': minor +'@astrojs/vercel': minor +--- + +Support for 404 and 500 pages in SSR diff --git a/packages/astro/src/core/app/index.ts b/packages/astro/src/core/app/index.ts index 518c1fc582f8..7adde8820658 100644 --- a/packages/astro/src/core/app/index.ts +++ b/packages/astro/src/core/app/index.ts @@ -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; @@ -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, @@ -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}].`); } @@ -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; @@ -128,6 +159,7 @@ export class App { ssr: true, request, streaming: this.#streaming, + status }); return response; @@ -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; @@ -155,6 +188,7 @@ export class App { route: routeData, routeCache: this.#routeCache, ssr: true, + status }); if (result.type === 'response') { diff --git a/packages/astro/src/core/endpoint/index.ts b/packages/astro/src/core/endpoint/index.ts index 2e0318d58d46..117953dac505 100644 --- a/packages/astro/src/core/endpoint/index.ts +++ b/packages/astro/src/core/endpoint/index.ts @@ -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 = diff --git a/packages/astro/src/core/render/core.ts b/packages/astro/src/core/render/core.ts index df8cb49d2765..94aa62ac837b 100644 --- a/packages/astro/src/core/render/core.ts +++ b/packages/astro/src/core/render/core.ts @@ -85,6 +85,7 @@ export interface RenderOptions { ssr: boolean; streaming: boolean; request: Request; + status?: number; } export async function render(opts: RenderOptions): Promise<Response> { @@ -107,6 +108,7 @@ export async function render(opts: RenderOptions): Promise<Response> { site, ssr, streaming, + status = 200 } = opts; const paramsAndPropsRes = await getParamsAndProps({ @@ -148,6 +150,7 @@ export async function render(opts: RenderOptions): Promise<Response> { scripts, ssr, streaming, + status }); // Support `export const components` for `MDX` pages diff --git a/packages/astro/src/core/render/result.ts b/packages/astro/src/core/render/result.ts index a7f36ee7933b..6e5da1d699ce 100644 --- a/packages/astro/src/core/render/result.ts +++ b/packages/astro/src/core/render/result.ts @@ -42,6 +42,7 @@ export interface CreateResultArgs { scripts?: Set<SSRElement>; styles?: Set<SSRElement>; request: Request; + status: number; } function getFunctionExpression(slot: any) { @@ -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, }; diff --git a/packages/astro/test/fixtures/ssr-api-route-custom-404/src/pages/404.astro b/packages/astro/test/fixtures/ssr-api-route-custom-404/src/pages/404.astro index 658f0ef9b57f..71a4a4d2cb99 100644 --- a/packages/astro/test/fixtures/ssr-api-route-custom-404/src/pages/404.astro +++ b/packages/astro/test/fixtures/ssr-api-route-custom-404/src/pages/404.astro @@ -1,5 +1 @@ ---- - ---- - -<h1>Something went horribly wrong!!</h1> \ No newline at end of file +<h1>Something went horribly wrong!</h1> diff --git a/packages/astro/test/fixtures/ssr-api-route-custom-404/src/pages/500.astro b/packages/astro/test/fixtures/ssr-api-route-custom-404/src/pages/500.astro index 28a3b7cc21bb..0e36085e2d47 100644 --- a/packages/astro/test/fixtures/ssr-api-route-custom-404/src/pages/500.astro +++ b/packages/astro/test/fixtures/ssr-api-route-custom-404/src/pages/500.astro @@ -1,3 +1 @@ ---- -throw new Error(`oops`); ---- +<h1>This is an error page</h1> diff --git a/packages/astro/test/fixtures/ssr-api-route-custom-404/src/pages/causes-error.astro b/packages/astro/test/fixtures/ssr-api-route-custom-404/src/pages/causes-error.astro new file mode 100644 index 000000000000..28a3b7cc21bb --- /dev/null +++ b/packages/astro/test/fixtures/ssr-api-route-custom-404/src/pages/causes-error.astro @@ -0,0 +1,3 @@ +--- +throw new Error(`oops`); +--- diff --git a/packages/astro/test/ssr-404-500-pages.test.js b/packages/astro/test/ssr-404-500-pages.test.js new file mode 100644 index 000000000000..45d60d4baa16 --- /dev/null +++ b/packages/astro/test/ssr-404-500-pages.test.js @@ -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'); + }); +}); diff --git a/packages/astro/test/ssr-api-route.test.js b/packages/astro/test/ssr-api-route.test.js index 40d690f2dca8..ec6e65641434 100644 --- a/packages/astro/test/ssr-api-route.test.js +++ b/packages/astro/test/ssr-api-route.test.js @@ -1,12 +1,10 @@ import { expect } from 'chai'; -import * as cheerio from 'cheerio'; import { loadFixture } from './test-utils.js'; import testAdapter from './test-adapter.js'; describe('API routes in SSR', () => { /** @type {import('./test-utils').Fixture} */ let fixture; - let errorFixtures; before(async () => { fixture = await loadFixture({ @@ -16,97 +14,61 @@ describe('API routes in SSR', () => { }, adapter: testAdapter(), }); - errorFixtures = await loadFixture({ - root: './fixtures/ssr-api-route-custom-404/', - experimental: { - ssr: true, - }, - server: { - port: 5173 - }, - adapter: testAdapter(), - }); - await errorFixtures.build(); await fixture.build(); }); - // it('Basic pages work', async () => { - // const app = await fixture.loadTestAdapterApp(); - // const request = new Request('http://example.com/'); - // const response = await app.render(request); - // const html = await response.text(); - // expect(html).to.not.be.empty; - // }); + it('Basic pages work', async () => { + const app = await fixture.loadTestAdapterApp(); + const request = new Request('http://example.com/'); + const response = await app.render(request); + const html = await response.text(); + expect(html).to.not.be.empty; + }); - // it('Can load the API route too', async () => { - // const app = await fixture.loadTestAdapterApp(); - // const request = new Request('http://example.com/food.json'); - // const response = await app.render(request); - // expect(response.status).to.equal(200); - // expect(response.headers.get('Content-Type')).to.equal('application/json;charset=utf-8'); - // expect(response.headers.get('Content-Length')).to.not.be.empty; - // const body = await response.json(); - // expect(body.length).to.equal(3); - // }); + it('Can load the API route too', async () => { + const app = await fixture.loadTestAdapterApp(); + const request = new Request('http://example.com/food.json'); + const response = await app.render(request); + expect(response.status).to.equal(200); + expect(response.headers.get('Content-Type')).to.equal('application/json;charset=utf-8'); + expect(response.headers.get('Content-Length')).to.not.be.empty; + const body = await response.json(); + expect(body.length).to.equal(3); + }); describe('API Routes - Dev', () => { let devServer; - let errorDevServer; before(async () => { devServer = await fixture.startDevServer(); - errorDevServer = await errorFixtures.startDevServer(); }); after(async () => { await devServer.stop(); - await errorDevServer.stop(); }); - // it('Can POST to API routes', async () => { - // const response = await fixture.fetch('/food.json', { - // method: 'POST', - // body: `some data`, - // }); - // expect(response.status).to.equal(200); - // const text = await response.text(); - // expect(text).to.equal(`ok`); - // }); - - // it('Infer content type with charset for { body } shorthand', async () => { - // const response = await fixture.fetch('/food.json', { - // method: 'GET', - // }); - // expect(response.headers.get('Content-Type')).to.equal('application/json;charset=utf-8'); - // }); - - // it('Can set multiple headers of the same type', async () => { - // const response = await fixture.fetch('/login', { - // method: 'POST', - // }); - // const setCookie = response.headers.get('set-cookie'); - // expect(setCookie).to.equal('foo=foo; HttpOnly, bar=bar; HttpOnly'); - // }); - - it('renders default 404 page for /404', async () => { - const html = await fixture.fetch('/404').then((res) => res.text()); - const $ = cheerio.load(html); - - expect($('h1').text()).to.equal('404: Not found'); - // expect($('p').text()).to.equal('/a/'); + it('Can POST to API routes', async () => { + const response = await fixture.fetch('/food.json', { + method: 'POST', + body: `some data`, + }); + expect(response.status).to.equal(200); + const text = await response.text(); + expect(text).to.equal(`ok`); }); - // it('renders custom 404 page for /a', async () => { - // const html = await errorFixtures.fetch('/a').then((res) => res.text()); - // const $ = cheerio.load(html); - - // expect($('h1').text()).to.equal('Something went horribly wrong!!'); - // }); - - // it('500 page for /500', async () => { - // const html = await fixture.fetch('/500').then((res) => res.text()); - // const $ = cheerio.load(html); + it('Infer content type with charset for { body } shorthand', async () => { + const response = await fixture.fetch('/food.json', { + method: 'GET', + }); + expect(response.headers.get('Content-Type')).to.equal('application/json;charset=utf-8'); + }); - // expect($('title').text().length).to.equal('Something went horribly wrong!!'); - // }); + it('Can set multiple headers of the same type', async () => { + const response = await fixture.fetch('/login', { + method: 'POST', + }); + const setCookie = response.headers.get('set-cookie'); + expect(setCookie).to.equal('foo=foo; HttpOnly, bar=bar; HttpOnly'); + }); }); }); diff --git a/packages/integrations/cloudflare/src/server.ts b/packages/integrations/cloudflare/src/server.ts index 097f29d37cc5..7b88c7b1e4d1 100644 --- a/packages/integrations/cloudflare/src/server.ts +++ b/packages/integrations/cloudflare/src/server.ts @@ -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, { diff --git a/packages/integrations/netlify/src/netlify-functions.ts b/packages/integrations/netlify/src/netlify-functions.ts index 0363fb8034a2..d40254f96eb8 100644 --- a/packages/integrations/netlify/src/netlify-functions.ts +++ b/packages/integrations/netlify/src/netlify-functions.ts @@ -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', @@ -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']); diff --git a/packages/integrations/vercel/src/serverless/entrypoint.ts b/packages/integrations/vercel/src/serverless/entrypoint.ts index 852aebefda4a..6b94f201cc42 100644 --- a/packages/integrations/vercel/src/serverless/entrypoint.ts +++ b/packages/integrations/vercel/src/serverless/entrypoint.ts @@ -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 }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 633ee1b9da6f..8a6601fde461 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1740,6 +1740,12 @@ importers: dependencies: astro: link:../../.. + packages/astro/test/fixtures/ssr-api-route-custom-404: + specifiers: + astro: workspace:* + dependencies: + astro: link:../../.. + packages/astro/test/fixtures/ssr-assets: specifiers: astro: workspace:*