Skip to content
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
7 changes: 7 additions & 0 deletions .changeset/fix-node-app-render-hash-fragment.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'astro': patch
---

Improved error handling in the rendering phase

Added defensive validation in `App.render()` and `#renderError()` to provide a descriptive error message when a route module doesn't have a valid page function.
5 changes: 5 additions & 0 deletions .changeset/lazy-jeans-wave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@astrojs/node': patch
---

Fixes an issue where some prendered pages weren't correctly rendered when using the Node.js adapter in middleware mode.
18 changes: 18 additions & 0 deletions packages/astro/src/core/app/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -540,6 +540,14 @@ export class App {
// Load route module. We also catch its error here if it fails on initialization
const mod = await this.#pipeline.getModuleForRoute(routeData);

// Validate that the module has the expected structure
if (!mod || typeof mod.page !== 'function') {
throw new AstroError({
...AstroErrorData.FailedToFindPageMapSSR,
message: `The module for route "${routeData.route}" does not have a valid page function. This may occur when using static output mode with an SSR adapter.`,
});
}

const renderContext = await RenderContext.create({
pipeline: this.#pipeline,
locals,
Expand All @@ -552,6 +560,7 @@ export class App {
session = renderContext.session;
response = await renderContext.render(await mod.page());
} catch (err: any) {
this.#logger.error('router', 'Error while trying to render the route ' + routeData.route);
this.#logger.error(null, err.stack || err.message || String(err));
return this.#renderError(request, {
locals,
Expand Down Expand Up @@ -660,6 +669,15 @@ export class App {
}
}
const mod = await this.#pipeline.getModuleForRoute(errorRouteData);

// Validate that the module has the expected structure
if (!mod || typeof mod.page !== 'function') {
// If error page module is invalid, return a basic error response
const response = this.#mergeResponses(new Response(null, { status }), originalResponse);
Reflect.set(response, responseSentSymbol, true);
return response;
}

let session: AstroSession | undefined;
try {
const renderContext = await RenderContext.create({
Expand Down
3 changes: 3 additions & 0 deletions packages/integrations/node/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@
"cheerio": "1.1.2",
"devalue": "^5.6.1",
"express": "^4.22.1",
"fastify": "^5.6.2",
"@fastify/middie": "^9.1.0",
"@fastify/static": "^9.0.0",
"node-mocks-http": "^1.17.2"
},
"publishConfig": {
Expand Down
8 changes: 7 additions & 1 deletion packages/integrations/node/src/serve-static.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,13 @@ export function createStaticHandler(app: NodeApp, options: Options) {
*/
return (req: IncomingMessage, res: ServerResponse, ssr: () => unknown) => {
if (req.url) {
const [urlPath, urlQuery] = req.url.split('?');
// There might be cases where the incoming URL has the #, which we want to remove.
let fullUrl = req.url;
if (req.url.includes('#')) {
fullUrl = fullUrl.slice(0, req.url.indexOf('#'));
}

const [urlPath, urlQuery] = fullUrl.split('?');
const filePath = path.join(client, app.removeBase(urlPath));

let isDirectory = false;
Expand Down
14 changes: 14 additions & 0 deletions packages/integrations/node/test/bad-urls.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,4 +46,18 @@ describe('Bad URLs', () => {
const text = await stillWork.text();
assert.equal(text, '<!DOCTYPE html>Hello!');
});

it('Does not crash on URLs with hash fragments', async () => {
// Hash fragments are client-side only and stripped before reaching the server
// so /#foo should resolve to / and return 200 - see issue #14625
const hashURLs = ['/#', '/#foo', '/#/path'];
for (const hashUrl of hashURLs) {
const fetchResult = await fixture.fetch(hashUrl);
assert.equal(
fetchResult.status,
200,
`${hashUrl} should return 200 as hash fragments are stripped`,
);
}
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
export const prerender = false;

interface Props {
dynamic: string
}

export async function getStaticPaths() {
return [
{ params: { dynamic: 'foo' } },
{ params: { dynamic: 'bar' } }
];
}

const { dynamic } = Astro.params;

---

<html lang="en">
<head><title>node-middleware</title></head>
<style>
</style>
<body>
<div>{dynamic}</div>
</body>
</html>
135 changes: 133 additions & 2 deletions packages/integrations/node/test/node-middleware.test.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import * as assert from 'node:assert/strict';
import { after, before, describe, it } from 'node:test';
import { fileURLToPath } from 'node:url';
import fastifyMiddie from '@fastify/middie';
import fastifyStatic from '@fastify/static';
import * as cheerio from 'cheerio';
import express from 'express';
import Fastify from 'fastify';
import nodejs from '../dist/index.js';
import { loadFixture, waitServerListen } from './test-utils.js';

Expand Down Expand Up @@ -50,7 +54,7 @@ describe('behavior from middleware, standalone', () => {
});
});

describe('behavior from middleware, middleware', () => {
describe('behavior from middleware, middleware with express', () => {
/** @type {import('./test-utils').Fixture} */
let fixture;
let server;
Expand All @@ -76,7 +80,7 @@ describe('behavior from middleware, middleware', () => {
delete process.env.PRERENDER;
});

it('when mode is middleware', async () => {
it('should render the endpoint', async () => {
const res = await fetch('http://localhost:8889/ssr');

assert.equal(res.status, 200);
Expand All @@ -87,4 +91,131 @@ describe('behavior from middleware, middleware', () => {
const body = $('body');
assert.equal(body.text().includes("Here's a random number"), true);
});

it('should render the index.html page [static]', async () => {
const res = await fetch('http://localhost:8889/');

assert.equal(res.status, 200);

const html = await res.text();
const $ = cheerio.load(html);

const body = $('body');
assert.equal(body.text().includes('1'), true);
});

it('should render the index.html page [static] when the URL has the hash', async () => {
const res = await fetch('http://localhost:8889/#');

assert.equal(res.status, 200);

const html = await res.text();
const $ = cheerio.load(html);

const body = $('body');
assert.equal(body.text().includes('1'), true);
});

it('should render the dynamic pages', async () => {
let res = await fetch('http://localhost:8889/dyn/foo');

assert.equal(res.status, 200);

let html = await res.text();
let $ = cheerio.load(html);

let body = $('body');
assert.equal(body.text().includes('foo'), true);

res = await fetch('http://localhost:8889/dyn/bar');

assert.equal(res.status, 200);

html = await res.text();
$ = cheerio.load(html);

body = $('body');
assert.equal(body.text().includes('bar'), true);
});
});

describe('behavior from middleware, middleware with fastify', () => {
/** @type {import('./test-utils').Fixture} */
let fixture;
let server;

before(async () => {
process.env.PRERENDER = false;
fixture = await loadFixture({
root: './fixtures/node-middleware/',
output: 'server',
adapter: nodejs({ mode: 'middleware' }),
});
await fixture.build();
const { handler } = await fixture.loadAdapterEntryModule();
const app = Fastify({ logger: false });
await app
.register(fastifyStatic, {
root: fileURLToPath(new URL('./dist/client', import.meta.url)),
})
.register(fastifyMiddie);
app.use(handler);

await app.listen({ port: 8889 });

server = app;
});

after(async () => {
server.close();
await fixture.clean();

delete process.env.PRERENDER;
});

it('should render the endpoint', async () => {
const res = await fetch('http://localhost:8889/ssr');

assert.equal(res.status, 200);

const html = await res.text();
const $ = cheerio.load(html);

const body = $('body');
assert.equal(body.text().includes("Here's a random number"), true);
});

it('should render the index.html page [static]', async () => {
const res = await fetch('http://localhost:8889');

assert.equal(res.status, 200);

const html = await res.text();
const $ = cheerio.load(html);

const body = $('body');
assert.equal(body.text().includes('1'), true);
});

it('should render the dynamic pages', async () => {
let res = await fetch('http://localhost:8889/dyn/foo');

assert.equal(res.status, 200);

let html = await res.text();
let $ = cheerio.load(html);

let body = $('body');
assert.equal(body.text().includes('foo'), true);

res = await fetch('http://localhost:8889/dyn/bar');

assert.equal(res.status, 200);

html = await res.text();
$ = cheerio.load(html);

body = $('body');
assert.equal(body.text().includes('bar'), true);
});
});
Loading
Loading