Skip to content

Commit

Permalink
feat(node): add trailing slash support
Browse files Browse the repository at this point in the history
  • Loading branch information
msxdan committed Nov 13, 2023
1 parent 5f2db42 commit ddf320e
Show file tree
Hide file tree
Showing 10 changed files with 445 additions and 25 deletions.
65 changes: 48 additions & 17 deletions packages/integrations/node/src/http-server.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import type { AstroUserConfig } from 'astro/config';
import https from 'https';
import fs from 'node:fs';
import http from 'node:http';
import { fileURLToPath } from 'node:url';
import path from 'path';
import send from 'send';
import enableDestroy from 'server-destroy';

Expand All @@ -23,12 +25,55 @@ function parsePathname(pathname: string, host: string | undefined, port: number)

export function createServer(
{ client, port, host, removeBase }: CreateServerOptions,
handler: http.RequestListener
handler: http.RequestListener,
trailingSlash: AstroUserConfig['trailingSlash']
) {
const listener: http.RequestListener = (req, res) => {
if (req.url) {
let pathname: string | undefined = removeBase(req.url);
pathname = pathname[0] === '/' ? pathname : '/' + pathname;
const [urlPath, urlQuery] = req.url.split('?');
const filePath = path.join(fileURLToPath(client), removeBase(urlPath));

let pathname: string;
let isDirectory = false;
try {
isDirectory = fs.lstatSync(filePath).isDirectory();
}
catch (err) { }

if (!trailingSlash) // should never happen
trailingSlash = 'ignore';

const hasSlash = urlPath.endsWith('/');
switch (trailingSlash) {
case "never":
if (isDirectory && hasSlash) {
pathname = urlPath.slice(0, -1) + (urlQuery ? "?" + urlQuery : "");
res.statusCode = 301;
res.setHeader('Location', pathname);
} 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);
} else
pathname = urlPath;
break;
}
pathname = removeBase(pathname);

if (urlQuery && !pathname.includes('?')) {
pathname = pathname + '?' + urlQuery;
}
const encodedURI = parsePathname(pathname, host, port);

if (!encodedURI) {
Expand All @@ -54,20 +99,6 @@ export function createServer(
// File not found, forward to the SSR handler
handler(req, res);
});
stream.on('directory', () => {
// On directory find, redirect to the trailing slash
let location: string;
if (req.url!.includes('?')) {
const [url = '', search] = req.url!.split('?');
location = `${url}/?${search}`;
} else {
location = req.url + '/';
}

res.statusCode = 301;
res.setHeader('Location', location);
res.end(location);
});
stream.on('file', () => {
forwardError = true;
});
Expand Down
1 change: 1 addition & 0 deletions packages/integrations/node/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export default function createIntegration(userOptions: UserOptions): AstroIntegr
server: config.build.server?.toString(),
host: config.server.host,
port: config.server.port,
trailingSlash: config.trailingSlash
};
setAdapter(getAdapter(_options));

Expand Down
3 changes: 2 additions & 1 deletion packages/integrations/node/src/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,8 @@ const preview: CreatePreviewServer = async function ({
host,
removeBase,
},
handler
handler,
'ignore'
);
const address = getNetworkAddress('http', host, port);

Expand Down
3 changes: 2 additions & 1 deletion packages/integrations/node/src/standalone.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,8 @@ export default function startServer(app: NodeApp, options: Options) {
host,
removeBase: app.removeBase.bind(app),
},
handler
handler,
options.trailingSlash
);

const protocol = server.server instanceof https.Server ? 'https' : 'http';
Expand Down
2 changes: 2 additions & 0 deletions packages/integrations/node/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { AstroUserConfig } from 'astro';
import { IncomingMessage, ServerResponse } from 'node:http';

export interface UserOptions {
Expand All @@ -15,6 +16,7 @@ export interface Options extends UserOptions {
port: number;
server: string;
client: string;
trailingSlash: AstroUserConfig['trailingSlash'];
}

export type RequestHandlerParams = [
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"name": "@test/node-trailingslash",
"version": "0.0.0",
"private": true,
"dependencies": {
"astro": "workspace:*",
"@astrojs/node": "workspace:*"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
export const prerender = true;
---
<html>
<head>
<title>One</title>
</head>
<body>
<h1>One</h1>
</body>
</html>
16 changes: 10 additions & 6 deletions packages/integrations/node/test/prerender.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -75,12 +75,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');
});
});

Expand Down Expand Up @@ -206,12 +208,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');
});
});

Expand Down
Loading

0 comments on commit ddf320e

Please sign in to comment.