diff --git a/.changeset/calm-jobs-pay.md b/.changeset/calm-jobs-pay.md
new file mode 100644
index 0000000000..7036054139
--- /dev/null
+++ b/.changeset/calm-jobs-pay.md
@@ -0,0 +1,5 @@
+---
+'@astrojs/node': minor
+---
+
+Add trailingSlash support to NodeJS adapter
diff --git a/packages/integrations/node/src/serve-static.ts b/packages/integrations/node/src/serve-static.ts
index 77de9b3580..a88b1332f9 100644
--- a/packages/integrations/node/src/serve-static.ts
+++ b/packages/integrations/node/src/serve-static.ts
@@ -1,5 +1,6 @@
import path from 'node:path';
import url from 'node:url';
+import fs from 'node:fs';
import send from 'send';
import type { IncomingMessage, ServerResponse } from 'node:http';
import type { Options } from './types.js';
@@ -18,8 +19,47 @@ export function createStaticHandler(app: NodeApp, options: Options) {
*/
return (req: IncomingMessage, res: ServerResponse, ssr: () => unknown) => {
if (req.url) {
- let pathname = app.removeBase(req.url);
- pathname = decodeURI(new URL(pathname, 'http://host').pathname);
+ const [urlPath, urlQuery] = req.url.split('?');
+ const filePath = path.join(client, app.removeBase(urlPath));
+
+ let pathname: string;
+ let isDirectory = false;
+ try {
+ isDirectory = fs.lstatSync(filePath).isDirectory();
+ } catch {}
+
+ const { trailingSlash = 'ignore' } = options;
+
+ const hasSlash = urlPath.endsWith('/');
+ switch (trailingSlash) {
+ case "never":
+ if (isDirectory && (urlPath != '/') && hasSlash) {
+ pathname = urlPath.slice(0, -1) + (urlQuery ? "?" + urlQuery : "");
+ res.statusCode = 301;
+ res.setHeader('Location', pathname);
+ return res.end();
+ } else pathname = urlPath;
+ // intentionally fall through
+ case "ignore":
+ {
+ if (isDirectory && !hasSlash) {
+ pathname = urlPath + "/index.html";
+ } else
+ pathname = urlPath;
+ }
+ break;
+ case "always":
+ if (!hasSlash) {
+ pathname = urlPath + '/' +(urlQuery ? "?" + urlQuery : "");
+ res.statusCode = 301;
+ res.setHeader('Location', pathname);
+ return res.end();
+ } else
+ pathname = urlPath;
+ break;
+ }
+ // app.removeBase sometimes returns a path without a leading slash
+ pathname = prependForwardSlash(app.removeBase(pathname));
const stream = send(req, pathname, {
root: client,
@@ -47,20 +87,6 @@ export function createStaticHandler(app: NodeApp, options: Options) {
_res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
}
});
- stream.on('directory', () => {
- // On directory find, redirect to the trailing slash
- let location: string;
- if (req.url!.includes('?')) {
- const [url1 = '', search] = req.url!.split('?');
- location = `${url1}/?${search}`;
- } else {
- location = appendForwardSlash(req.url!);
- }
-
- res.statusCode = 301;
- res.setHeader('Location', location);
- res.end(location);
- });
stream.on('file', () => {
forwardError = true;
});
@@ -81,6 +107,10 @@ function resolveClientDir(options: Options) {
return client;
}
+function prependForwardSlash(pth: string) {
+ return pth.startsWith('/') ? pth : '/' + pth;
+}
+
function appendForwardSlash(pth: string) {
return pth.endsWith('/') ? pth : pth + '/';
}
diff --git a/packages/integrations/node/src/server.ts b/packages/integrations/node/src/server.ts
index d9f24cca5e..73b59c53fc 100644
--- a/packages/integrations/node/src/server.ts
+++ b/packages/integrations/node/src/server.ts
@@ -8,6 +8,7 @@ import type { Options } from './types.js';
applyPolyfills();
export function createExports(manifest: SSRManifest, options: Options) {
const app = new NodeApp(manifest);
+ options.trailingSlash = manifest.trailingSlash;
return {
options: options,
handler:
diff --git a/packages/integrations/node/src/types.ts b/packages/integrations/node/src/types.ts
index 9e4f4ce919..3c03dffac4 100644
--- a/packages/integrations/node/src/types.ts
+++ b/packages/integrations/node/src/types.ts
@@ -1,5 +1,6 @@
import type { NodeApp } from 'astro/app/node';
import type { IncomingMessage, ServerResponse } from 'node:http';
+import type { SSRManifest } from 'astro';
export interface UserOptions {
/**
@@ -17,6 +18,7 @@ export interface Options extends UserOptions {
server: string;
client: string;
assets: string;
+ trailingSlash?: SSRManifest['trailingSlash'];
}
export interface CreateServerOptions {
diff --git a/packages/integrations/node/test/fixtures/trailing-slash/astro.config.mjs b/packages/integrations/node/test/fixtures/trailing-slash/astro.config.mjs
new file mode 100644
index 0000000000..7ee28f2134
--- /dev/null
+++ b/packages/integrations/node/test/fixtures/trailing-slash/astro.config.mjs
@@ -0,0 +1,8 @@
+import node from '@astrojs/node'
+
+export default {
+ base: '/some-base',
+ output: 'hybrid',
+ trailingSlash: 'never',
+ adapter: node({ mode: 'standalone' })
+};
diff --git a/packages/integrations/node/test/fixtures/trailing-slash/package.json b/packages/integrations/node/test/fixtures/trailing-slash/package.json
new file mode 100644
index 0000000000..50b7b7201b
--- /dev/null
+++ b/packages/integrations/node/test/fixtures/trailing-slash/package.json
@@ -0,0 +1,9 @@
+{
+ "name": "@test/node-trailingslash",
+ "version": "0.0.0",
+ "private": true,
+ "dependencies": {
+ "astro": "workspace:*",
+ "@astrojs/node": "workspace:*"
+ }
+}
diff --git a/packages/integrations/node/test/fixtures/trailing-slash/src/pages/index.astro b/packages/integrations/node/test/fixtures/trailing-slash/src/pages/index.astro
new file mode 100644
index 0000000000..a4c415519a
--- /dev/null
+++ b/packages/integrations/node/test/fixtures/trailing-slash/src/pages/index.astro
@@ -0,0 +1,8 @@
+
+
+ Index
+
+
+ Index
+
+
diff --git a/packages/integrations/node/test/fixtures/trailing-slash/src/pages/one.astro b/packages/integrations/node/test/fixtures/trailing-slash/src/pages/one.astro
new file mode 100644
index 0000000000..aa370d18df
--- /dev/null
+++ b/packages/integrations/node/test/fixtures/trailing-slash/src/pages/one.astro
@@ -0,0 +1,11 @@
+---
+export const prerender = true;
+---
+
+
+ One
+
+
+ One
+
+
diff --git a/packages/integrations/node/test/prerender.test.js b/packages/integrations/node/test/prerender.test.js
index 84f599bcda..86a7d3a656 100644
--- a/packages/integrations/node/test/prerender.test.js
+++ b/packages/integrations/node/test/prerender.test.js
@@ -74,12 +74,14 @@ describe('Prerendering', () => {
expect($('h1').text()).to.equal('Two');
});
- it('Omitting the trailing slash results in a redirect that includes the base', async () => {
+ it('Can render prerendered route without trailing slash', async () => {
const res = await fetch(`http://${server.host}:${server.port}/some-base/two`, {
redirect: 'manual',
});
- expect(res.status).to.equal(301);
- expect(res.headers.get('location')).to.equal('/some-base/two/');
+ const html = await res.text();
+ const $ = cheerio.load(html);
+ expect(res.status).to.equal(200);
+ expect($('h1').text()).to.equal('Two');
});
});
@@ -241,12 +243,14 @@ describe('Hybrid rendering', () => {
expect($('h1').text()).to.equal('One');
});
- it('Omitting the trailing slash results in a redirect that includes the base', async () => {
+ it('Can render prerendered route without trailing slash', async () => {
const res = await fetch(`http://${server.host}:${server.port}/some-base/one`, {
redirect: 'manual',
});
- expect(res.status).to.equal(301);
- expect(res.headers.get('location')).to.equal('/some-base/one/');
+ const html = await res.text();
+ const $ = cheerio.load(html);
+ expect(res.status).to.equal(200);
+ expect($('h1').text()).to.equal('One');
});
});
diff --git a/packages/integrations/node/test/trailing-slash.js b/packages/integrations/node/test/trailing-slash.js
new file mode 100644
index 0000000000..cdfef6d106
--- /dev/null
+++ b/packages/integrations/node/test/trailing-slash.js
@@ -0,0 +1,405 @@
+import nodejs from '../dist/index.js';
+import { loadFixture } from './test-utils.js';
+import { expect } from 'chai';
+import * as cheerio from 'cheerio';
+
+/**
+ * @typedef {import('../../../astro/test/test-utils').Fixture} Fixture
+ */
+
+async function load() {
+ const mod = await import(`./fixtures/trailing-slash/dist/server/entry.mjs?dropcache=${Date.now()}`);
+ return mod;
+}
+
+describe('Trailing slash', () => {
+ /** @type {import('./test-utils').Fixture} */
+ let fixture;
+ let server;
+ describe('Always', async () => {
+ describe('With base', async () => {
+ before(async () => {
+ process.env.ASTRO_NODE_AUTOSTART = 'disabled';
+ process.env.PRERENDER = true;
+
+ fixture = await loadFixture({
+ root: './fixtures/trailing-slash/',
+ base: '/some-base',
+ output: 'hybrid',
+ trailingSlash: 'always',
+ adapter: nodejs({ mode: 'standalone' }),
+ });
+ await fixture.build();
+ const { startServer } = await load();
+ let res = startServer();
+ server = res.server;
+ });
+
+ after(async () => {
+ await server.stop();
+ await fixture.clean();
+ delete process.env.PRERENDER;
+ });
+
+ it('Can render prerendered base route', async () => {
+ const res = await fetch(`http://${server.host}:${server.port}`);
+ const html = await res.text();
+ const $ = cheerio.load(html);
+
+ expect(res.status).to.equal(200);
+ expect($('h1').text()).to.equal('Index');
+ });
+
+ it('Can render prerendered route with redirect', async () => {
+ const res = await fetch(`http://${server.host}:${server.port}/some-base/one`, {
+ redirect : 'manual'
+ });
+ expect(res.status).to.equal(301);
+ expect(res.headers.get('location')).to.equal('/some-base/one/');
+ });
+
+ it('Can render prerendered route with redirect and query params', async () => {
+ const res = await fetch(`http://${server.host}:${server.port}/some-base/one?foo=bar`, {
+ redirect : 'manual'
+ });
+ expect(res.status).to.equal(301);
+ expect(res.headers.get('location')).to.equal('/some-base/one/?foo=bar');
+ });
+
+ it('Can render prerendered route with query params', async () => {
+ const res = await fetch(`http://${server.host}:${server.port}/some-base/one/?foo=bar`);
+ const html = await res.text();
+ const $ = cheerio.load(html);
+
+ expect(res.status).to.equal(200);
+ expect($('h1').text()).to.equal('One');
+ });
+ });
+ describe('Without base', async () => {
+ before(async () => {
+ process.env.ASTRO_NODE_AUTOSTART = 'disabled';
+ process.env.PRERENDER = true;
+
+ fixture = await loadFixture({
+ root: './fixtures/trailing-slash/',
+ output: 'hybrid',
+ trailingSlash: 'always',
+ adapter: nodejs({ mode: 'standalone' }),
+ });
+ await fixture.build();
+ const { startServer } = await load();
+ let res = startServer();
+ server = res.server;
+ });
+
+ after(async () => {
+ await server.stop();
+ await fixture.clean();
+ delete process.env.PRERENDER;
+ });
+
+ it('Can render prerendered base route', async () => {
+ const res = await fetch(`http://${server.host}:${server.port}`);
+ const html = await res.text();
+ const $ = cheerio.load(html);
+
+ expect(res.status).to.equal(200);
+ expect($('h1').text()).to.equal('Index');
+ });
+
+ it('Can render prerendered route with redirect', async () => {
+ const res = await fetch(`http://${server.host}:${server.port}/one`, {
+ redirect : 'manual'
+ });
+ expect(res.status).to.equal(301);
+ expect(res.headers.get('location')).to.equal('/one/');
+ });
+
+ it('Can render prerendered route with redirect and query params', async () => {
+ const res = await fetch(`http://${server.host}:${server.port}/one?foo=bar`, {
+ redirect : 'manual'
+ });
+ expect(res.status).to.equal(301);
+ expect(res.headers.get('location')).to.equal('/one/?foo=bar');
+ });
+
+ it('Can render prerendered route with query params', async () => {
+ const res = await fetch(`http://${server.host}:${server.port}/one/?foo=bar`);
+ const html = await res.text();
+ const $ = cheerio.load(html);
+
+ expect(res.status).to.equal(200);
+ expect($('h1').text()).to.equal('One');
+ });
+ });
+ });
+ describe('Never', async () => {
+ describe('With base', async () => {
+ before(async () => {
+ process.env.ASTRO_NODE_AUTOSTART = 'disabled';
+ process.env.PRERENDER = true;
+
+ fixture = await loadFixture({
+ root: './fixtures/trailing-slash/',
+ base: '/some-base',
+ output: 'hybrid',
+ trailingSlash: 'never',
+ adapter: nodejs({ mode: 'standalone' }),
+ });
+ await fixture.build();
+ const { startServer } = await load();
+ let res = startServer();
+ server = res.server;
+ });
+
+ after(async () => {
+ await server.stop();
+ await fixture.clean();
+ delete process.env.PRERENDER;
+ });
+
+ it('Can render prerendered base route', async () => {
+ const res = await fetch(`http://${server.host}:${server.port}`);
+ const html = await res.text();
+ const $ = cheerio.load(html);
+
+ expect(res.status).to.equal(200);
+ expect($('h1').text()).to.equal('Index');
+ });
+
+ it('Can render prerendered route with redirect', async () => {
+ const res = await fetch(`http://${server.host}:${server.port}/some-base/one/`, {
+ redirect : 'manual'
+ });
+ expect(res.status).to.equal(301);
+ expect(res.headers.get('location')).to.equal('/some-base/one');
+ });
+
+ it('Can render prerendered route with redirect and query params', async () => {
+ const res = await fetch(`http://${server.host}:${server.port}/some-base/one/?foo=bar`, {
+ redirect : 'manual'
+ });
+
+ expect(res.status).to.equal(301);
+ expect(res.headers.get('location')).to.equal('/some-base/one?foo=bar');
+ });
+
+ it('Can render prerendered route with query params', async () => {
+ const res = await fetch(`http://${server.host}:${server.port}/some-base/one?foo=bar`);
+ const html = await res.text();
+ const $ = cheerio.load(html);
+
+ expect(res.status).to.equal(200);
+ expect($('h1').text()).to.equal('One');
+ });
+ });
+ describe('Without base', async () => {
+ before(async () => {
+ process.env.ASTRO_NODE_AUTOSTART = 'disabled';
+ process.env.PRERENDER = true;
+
+ fixture = await loadFixture({
+ root: './fixtures/trailing-slash/',
+ output: 'hybrid',
+ trailingSlash: 'never',
+ adapter: nodejs({ mode: 'standalone' }),
+ });
+ await fixture.build();
+ const { startServer } = await load();
+ let res = startServer();
+ server = res.server;
+ });
+
+ after(async () => {
+ await server.stop();
+ await fixture.clean();
+ delete process.env.PRERENDER;
+ });
+
+ it('Can render prerendered base route', async () => {
+ const res = await fetch(`http://${server.host}:${server.port}`);
+ const html = await res.text();
+ const $ = cheerio.load(html);
+
+ expect(res.status).to.equal(200);
+ expect($('h1').text()).to.equal('Index');
+ });
+
+ it('Can render prerendered route with redirect', async () => {
+ const res = await fetch(`http://${server.host}:${server.port}/one/`, {
+ redirect : 'manual'
+ });
+ expect(res.status).to.equal(301);
+ expect(res.headers.get('location')).to.equal('/one');
+ });
+
+ it('Can render prerendered route with redirect and query params', async () => {
+ const res = await fetch(`http://${server.host}:${server.port}/one/?foo=bar`, {
+ redirect : 'manual'
+ });
+
+ expect(res.status).to.equal(301);
+ expect(res.headers.get('location')).to.equal('/one?foo=bar');
+ });
+
+ it('Can render prerendered route and query params', async () => {
+ const res = await fetch(`http://${server.host}:${server.port}/one?foo=bar`);
+ const html = await res.text();
+ const $ = cheerio.load(html);
+
+ expect(res.status).to.equal(200);
+ expect($('h1').text()).to.equal('One');
+ });
+ });
+ });
+ describe('Ignore', async () => {
+ describe('With base', async () => {
+ before(async () => {
+ process.env.ASTRO_NODE_AUTOSTART = 'disabled';
+ process.env.PRERENDER = true;
+
+ fixture = await loadFixture({
+ root: './fixtures/trailing-slash/',
+ base: '/some-base',
+ output: 'hybrid',
+ trailingSlash: 'ignore',
+ adapter: nodejs({ mode: 'standalone' }),
+ });
+ await fixture.build();
+ const { startServer } = await load();
+ let res = startServer();
+ server = res.server;
+ });
+
+ after(async () => {
+ await server.stop();
+ await fixture.clean();
+ delete process.env.PRERENDER;
+ });
+
+ it('Can render prerendered base route', async () => {
+ const res = await fetch(`http://${server.host}:${server.port}`);
+ const html = await res.text();
+ const $ = cheerio.load(html);
+
+ expect(res.status).to.equal(200);
+ expect($('h1').text()).to.equal('Index');
+ });
+
+ it('Can render prerendered route with slash', async () => {
+ const res = await fetch(`http://${server.host}:${server.port}/some-base/one/`, {
+ redirect : 'manual'
+ });
+ const html = await res.text();
+ const $ = cheerio.load(html);
+
+ expect(res.status).to.equal(200);
+ expect($('h1').text()).to.equal('One');
+ });
+
+ it('Can render prerendered route without slash', async () => {
+ const res = await fetch(`http://${server.host}:${server.port}/some-base/one`, {
+ redirect : 'manual'
+ });
+ const html = await res.text();
+ const $ = cheerio.load(html);
+
+ expect(res.status).to.equal(200);
+ expect($('h1').text()).to.equal('One');
+ });
+
+ it('Can render prerendered route with slash and query params', async () => {
+ const res = await fetch(`http://${server.host}:${server.port}/some-base/one/?foo=bar`, {
+ redirect : 'manual'
+ });
+ const html = await res.text();
+ const $ = cheerio.load(html);
+
+ expect(res.status).to.equal(200);
+ expect($('h1').text()).to.equal('One');
+ });
+
+ it('Can render prerendered route without slash and with query params', async () => {
+ const res = await fetch(`http://${server.host}:${server.port}/some-base/one?foo=bar`, {
+ redirect : 'manual'
+ });
+ const html = await res.text();
+ const $ = cheerio.load(html);
+
+ expect(res.status).to.equal(200);
+ expect($('h1').text()).to.equal('One');
+ });
+ });
+ describe('Without base', async () => {
+ before(async () => {
+ process.env.ASTRO_NODE_AUTOSTART = 'disabled';
+ process.env.PRERENDER = true;
+
+ fixture = await loadFixture({
+ root: './fixtures/trailing-slash/',
+ output: 'hybrid',
+ trailingSlash: 'ignore',
+ adapter: nodejs({ mode: 'standalone' }),
+ });
+ await fixture.build();
+ const { startServer } = await load();
+ let res = startServer();
+ server = res.server;
+ });
+
+ after(async () => {
+ await server.stop();
+ await fixture.clean();
+ delete process.env.PRERENDER;
+ });
+
+ it('Can render prerendered base route', async () => {
+ const res = await fetch(`http://${server.host}:${server.port}`);
+ const html = await res.text();
+ const $ = cheerio.load(html);
+
+ expect(res.status).to.equal(200);
+ expect($('h1').text()).to.equal('Index');
+ });
+
+ it('Can render prerendered route with slash', async () => {
+ const res = await fetch(`http://${server.host}:${server.port}/one/`);
+ const html = await res.text();
+ const $ = cheerio.load(html);
+
+ expect(res.status).to.equal(200);
+ expect($('h1').text()).to.equal('One');
+ });
+
+ it('Can render prerendered route without slash', async () => {
+ const res = await fetch(`http://${server.host}:${server.port}/one`);
+ const html = await res.text();
+ const $ = cheerio.load(html);
+
+ expect(res.status).to.equal(200);
+ expect($('h1').text()).to.equal('One');
+ });
+
+ it('Can render prerendered route with slash and query params', async () => {
+ const res = await fetch(`http://${server.host}:${server.port}/one/?foo=bar`, {
+ redirect : 'manual'
+ });
+ const html = await res.text();
+ const $ = cheerio.load(html);
+
+ expect(res.status).to.equal(200);
+ expect($('h1').text()).to.equal('One');
+ });
+
+ it('Can render prerendered route without slash and with query params', async () => {
+ const res = await fetch(`http://${server.host}:${server.port}/one?foo=bar`);
+ const html = await res.text();
+ const $ = cheerio.load(html);
+
+ expect(res.status).to.equal(200);
+ expect($('h1').text()).to.equal('One');
+ });
+ });
+ });
+});
+
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index a624b9e4ae..53147bf4e6 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -4456,6 +4456,15 @@ importers:
specifier: workspace:*
version: link:../../../../../astro
+ packages/integrations/node/test/fixtures/trailing-slash:
+ dependencies:
+ '@astrojs/node':
+ specifier: workspace:*
+ version: link:../../..
+ astro:
+ specifier: workspace:*
+ version: link:../../../../../astro
+
packages/integrations/node/test/fixtures/url-protocol:
dependencies:
'@astrojs/node':