Skip to content

Commit dcb9526

Browse files
yurynixascorbicematipicosarah11918
authored
Allow to pass custom fetch for fetching pre-rendered errors (#13403)
* Allow to pass custom fetch for fetching pre-rendered errors * Add changeset * Update lockfile * Update .changeset/big-hats-train.md * Add JSDoc to the new param * Commit suggested rephrase * Change the suffix of the sentence * Correct the test, it was using proper fetch signature, which is not actually what we need * Add a more strict type to the error page path * State explicitly that it's only 404 or 500 * Rephrase the JSdoc * rename to `prerenderedErrorPageFetch` * rename to `prerenderedErrorPageFetch` * Update .changeset/big-hats-train.md Co-authored-by: Sarah Rainsberger <[email protected]> * update the changeset * update tests * Apply suggestions from code review Co-authored-by: Sarah Rainsberger <[email protected]> * Update .changeset/big-hats-train.md Co-authored-by: Matt Kane <[email protected]> --------- Co-authored-by: Matt Kane <[email protected]> Co-authored-by: Emanuele Stoppa <[email protected]> Co-authored-by: Sarah Rainsberger <[email protected]>
1 parent 2279053 commit dcb9526

File tree

11 files changed

+937
-514
lines changed

11 files changed

+937
-514
lines changed

.changeset/big-hats-train.md

+29
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
'astro': minor
3+
---
4+
5+
Adds a new optional `prerenderedErrorPageFetch` option in the Adapter API to allow adapters to provide custom implementations for fetching prerendered error pages.
6+
7+
Now, adapters can override the default `fetch()` behavior, for example when `fetch()` is unavailable or when you cannot call the server from itself.
8+
9+
The following example provides a custom fetch for `500.html` and `404.html`, reading them from disk instead of performing an HTTP call:
10+
```js "prerenderedErrorPageFetch"
11+
return app.render(request, {
12+
prerenderedErrorPageFetch: async (url: string): Promise<Response> => {
13+
if (url.includes("/500")) {
14+
const content = await fs.promises.readFile("500.html", "utf-8");
15+
return new Response(content, {
16+
status: 500,
17+
headers: { "Content-Type": "text/html" },
18+
});
19+
}
20+
const content = await fs.promises.readFile("404.html", "utf-8");
21+
return new Response(content, {
22+
status: 404,
23+
headers: { "Content-Type": "text/html" },
24+
});
25+
});
26+
```
27+
If no value is provided, Astro will fallback to its default behavior for fetching error pages.
28+
29+
Read more about this feature in the [Adapter API reference](https://docs.astro.build/en/reference/adapter-reference/#prerenderederrorpagefetch).

packages/astro/src/core/app/index.ts

+32-4
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,15 @@ import { AppPipeline } from './pipeline.js';
3131

3232
export { deserializeManifest } from './common.js';
3333

34+
35+
type ErrorPagePath =
36+
| `${string}/404`
37+
| `${string}/500`
38+
| `${string}/404/`
39+
| `${string}/500/`
40+
| `${string}404.html`
41+
| `${string}500.html`;
42+
3443
export interface RenderOptions {
3544
/**
3645
* Whether to automatically add all cookies written by `Astro.cookie.set()` to the response headers.
@@ -55,6 +64,20 @@ export interface RenderOptions {
5564
*/
5665
locals?: object;
5766

67+
/**
68+
* A custom fetch function for retrieving prerendered pages - 404 or 500.
69+
*
70+
* If not provided, Astro will fallback to its default behavior for fetching error pages.
71+
*
72+
* When a dynamic route is matched but ultimately results in a 404, this function will be used
73+
* to fetch the prerendered 404 page if available. Similarly, it may be used to fetch a
74+
* prerendered 500 error page when necessary.
75+
*
76+
* @param {ErrorPagePath} url - The URL of the prerendered 404 or 500 error page to fetch.
77+
* @returns {Promise<Response>} A promise resolving to the prerendered response.
78+
*/
79+
prerenderedErrorPageFetch?: (url: ErrorPagePath) => Promise<Response>;
80+
5881
/**
5982
* **Advanced API**: you probably do not need to use this.
6083
*
@@ -77,6 +100,7 @@ export interface RenderErrorOptions {
77100
*/
78101
error?: unknown;
79102
clientAddress: string | undefined;
103+
prerenderedErrorPageFetch: (url: ErrorPagePath) => Promise<Response>;
80104
}
81105

82106
export class App {
@@ -287,6 +311,7 @@ export class App {
287311
let addCookieHeader: boolean | undefined;
288312
const url = new URL(request.url);
289313
const redirect = this.#redirectTrailingSlash(url.pathname);
314+
const prerenderedErrorPageFetch = renderOptions?.prerenderedErrorPageFetch ?? fetch;
290315

291316
if (redirect !== url.pathname) {
292317
const status = request.method === 'GET' ? 301 : 308;
@@ -323,7 +348,7 @@ export class App {
323348
if (typeof locals !== 'object') {
324349
const error = new AstroError(AstroErrorData.LocalsNotAnObject);
325350
this.#logger.error(null, error.stack!);
326-
return this.#renderError(request, { status: 500, error, clientAddress });
351+
return this.#renderError(request, { status: 500, error, clientAddress, prerenderedErrorPageFetch: prerenderedErrorPageFetch, });
327352
}
328353
}
329354
if (!routeData) {
@@ -342,7 +367,7 @@ export class App {
342367
if (!routeData) {
343368
this.#logger.debug('router', "Astro hasn't found routes that match " + request.url);
344369
this.#logger.debug('router', "Here's the available routes:\n", this.#manifestData);
345-
return this.#renderError(request, { locals, status: 404, clientAddress });
370+
return this.#renderError(request, { locals, status: 404, clientAddress, prerenderedErrorPageFetch: prerenderedErrorPageFetch });
346371
}
347372
const pathname = this.#getPathnameFromRequest(request);
348373
const defaultStatus = this.#getDefaultStatusCode(routeData, pathname);
@@ -366,7 +391,7 @@ export class App {
366391
response = await renderContext.render(await mod.page());
367392
} catch (err: any) {
368393
this.#logger.error(null, err.stack || err.message || String(err));
369-
return this.#renderError(request, { locals, status: 500, error: err, clientAddress });
394+
return this.#renderError(request, { locals, status: 500, error: err, clientAddress, prerenderedErrorPageFetch: prerenderedErrorPageFetch });
370395
} finally {
371396
await session?.[PERSIST_SYMBOL]();
372397
}
@@ -383,6 +408,7 @@ export class App {
383408
// while undefined means there's no error
384409
error: response.status === 500 ? null : undefined,
385410
clientAddress,
411+
prerenderedErrorPageFetch: prerenderedErrorPageFetch,
386412
});
387413
}
388414

@@ -431,6 +457,7 @@ export class App {
431457
skipMiddleware = false,
432458
error,
433459
clientAddress,
460+
prerenderedErrorPageFetch,
434461
}: RenderErrorOptions,
435462
): Promise<Response> {
436463
const errorRoutePath = `/${status}${this.#manifest.trailingSlash === 'always' ? '/' : ''}`;
@@ -444,7 +471,7 @@ export class App {
444471
url,
445472
);
446473
if (statusURL.toString() !== request.url) {
447-
const response = await fetch(statusURL.toString());
474+
const response = await prerenderedErrorPageFetch(statusURL.toString() as ErrorPagePath);
448475

449476
// response for /404.html and 500.html is 200, which is not meaningful
450477
// so we create an override
@@ -479,6 +506,7 @@ export class App {
479506
response: originalResponse,
480507
skipMiddleware: true,
481508
clientAddress,
509+
prerenderedErrorPageFetch,
482510
});
483511
}
484512
} finally {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import assert from 'node:assert/strict';
2+
import { before, describe, it, beforeEach } from 'node:test';
3+
import * as cheerio from 'cheerio';
4+
import testAdapter from './test-adapter.js';
5+
import { loadFixture } from './test-utils.js';
6+
7+
describe('Custom Fetch for Error Pages', () => {
8+
/** @type {import('./test-utils.js').Fixture} */
9+
let fixture;
10+
11+
before(async () => {
12+
fixture = await loadFixture({
13+
root: './fixtures/custom-fetch-error-pages/',
14+
output: 'server',
15+
adapter: testAdapter(),
16+
build: { inlineStylesheets: 'never' },
17+
});
18+
});
19+
20+
describe('Production', () => {
21+
/** @type {import('./test-utils.js').App} */
22+
let app;
23+
24+
// Mock fetch calls for tracking
25+
let fetchCalls = [];
26+
const customFetch = async (url) => {
27+
fetchCalls.push(url);
28+
// Return a custom response to verify our fetch was used
29+
return new Response('<html><body><h1>Custom Fetch Response</h1></body></html>', {
30+
headers: {
31+
'content-type': 'text/html',
32+
},
33+
});
34+
};
35+
36+
before(async () => {
37+
await fixture.build({});
38+
app = await fixture.loadTestAdapterApp();
39+
});
40+
41+
beforeEach(() => {
42+
// Reset fetch calls before each test
43+
fetchCalls = [];
44+
});
45+
46+
it('uses custom fetch implementation in case the server needs to get pre-rendered error 404 page when provided via preRenderedFetch', async () => {
47+
const request = new Request('http://example.com/not-found');
48+
const response = await app.render(request, { prerenderedErrorPageFetch: customFetch });
49+
50+
// Verify the response comes from our custom fetch
51+
assert.equal(response.status, 404);
52+
53+
// Verify our custom fetch was called with the right URL
54+
assert.equal(fetchCalls.length, 1);
55+
assert.ok(fetchCalls[0].includes('/404'));
56+
57+
const html = await response.text();
58+
const $ = cheerio.load(html);
59+
assert.equal($('h1').text(), 'Custom Fetch Response');
60+
});
61+
62+
it('uses custom fetch implementation for 500 errors', async () => {
63+
const request = new Request('http://example.com/causes-error');
64+
const response = await app.render(request, { prerenderedErrorPageFetch: customFetch });
65+
66+
// Verify the response comes from our custom fetch
67+
assert.equal(response.status, 500);
68+
69+
// Verify our custom fetch was called with the right URL
70+
assert.equal(fetchCalls.length, 1);
71+
assert.ok(fetchCalls[0].includes('/500'));
72+
73+
const html = await response.text();
74+
const $ = cheerio.load(html);
75+
assert.equal($('h1').text(), 'Custom Fetch Response');
76+
});
77+
78+
it('falls back to global fetch when preRenderedFetch is not provided', async () => {
79+
const request = new Request('http://example.com/not-found');
80+
const response = await app.render(request);
81+
82+
// Verify our custom fetch was NOT called
83+
assert.equal(fetchCalls.length, 0);
84+
85+
// Response should be the default 404 page
86+
assert.equal(response.status, 404);
87+
const html = await response.text();
88+
const $ = cheerio.load(html);
89+
assert.equal($('h1').text(), 'Example Domain'); // actual fetch requesting example.com and gets that.
90+
});
91+
});
92+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { defineConfig } from 'astro/config';
2+
3+
export default defineConfig({
4+
output: 'server',
5+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"name": "custom-fetch-error-pages",
3+
"version": "1.0.0",
4+
"private": true,
5+
"dependencies": {
6+
"astro": "workspace:*"
7+
}
8+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
// This is the 404 page
3+
export const prerender = true;
4+
---
5+
<html>
6+
<body>
7+
<h1>Something went horribly wrong!</h1>
8+
</body>
9+
</html>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
// This is the 500 error page
3+
export const prerender = true;
4+
---
5+
<html>
6+
<body>
7+
<h1>This is an error page</h1>
8+
</body>
9+
</html>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
const params = Astro.params;
3+
if (params.page && params.page.includes('not-found')) {
4+
return new Response(null, { status: 404 });
5+
}
6+
---
7+
8+
<html>
9+
<body>
10+
<h1>{new Date()}</h1>
11+
</body>
12+
</html>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
---
2+
throw new Error('Oops!');
3+
---

packages/astro/test/test-adapter.js

+2-2
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ export default function ({
6969
this.#manifest = manifest;
7070
}
7171
72-
async render(request, { routeData, clientAddress, locals, addCookieHeader } = {}) {
72+
async render(request, { routeData, clientAddress, locals, addCookieHeader, prerenderedErrorPageFetch } = {}) {
7373
const url = new URL(request.url);
7474
if(this.#manifest.assets.has(url.pathname)) {
7575
const filePath = new URL('../../client/' + this.removeBase(url.pathname), import.meta.url);
@@ -82,7 +82,7 @@ export default function ({
8282
? `request[Symbol.for('astro.clientAddress')] = clientAddress ?? '0.0.0.0';`
8383
: ''
8484
}
85-
return super.render(request, { routeData, locals, addCookieHeader });
85+
return super.render(request, { routeData, locals, addCookieHeader, prerenderedErrorPageFetch });
8686
}
8787
}
8888

0 commit comments

Comments
 (0)