Skip to content

Commit

Permalink
feat: add trailingSlash middleware (#174)
Browse files Browse the repository at this point in the history
  • Loading branch information
stevenle authored Aug 2, 2023
1 parent 8b0bafb commit c1dd173
Show file tree
Hide file tree
Showing 6 changed files with 103 additions and 3 deletions.
5 changes: 5 additions & 0 deletions .changeset/lazy-pans-fetch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@blinkk/root': patch
---

feat: add trailingSlash middleware
6 changes: 5 additions & 1 deletion packages/root/src/cli/commands/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@ import sirv from 'sirv';
import glob from 'tiny-glob';

import {RootConfig} from '../../core/config.js';
import {rootProjectMiddleware} from '../../core/middleware.js';
import {
rootProjectMiddleware,
trailingSlashMiddleware,
} from '../../core/middleware.js';
import {configureServerPlugins} from '../../core/plugin.js';
import {Server, Request, Response, NextFunction} from '../../core/types.js';
import {getElements, getElementsDirs} from '../../node/element-graph.js';
Expand Down Expand Up @@ -72,6 +75,7 @@ export async function createDevServer(options?: {
}

// Add the root.js dev server middlewares.
server.use(trailingSlashMiddleware({rootConfig}));
server.use(rootDevServerMiddleware());
server.use(rootDevServer404Middleware());
server.use(rootDevServer500Middleware());
Expand Down
6 changes: 5 additions & 1 deletion packages/root/src/cli/commands/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@ import {dim} from 'kleur/colors';
import sirv from 'sirv';

import {RootConfig} from '../../core/config';
import {rootProjectMiddleware} from '../../core/middleware';
import {
rootProjectMiddleware,
trailingSlashMiddleware,
} from '../../core/middleware';
import {configureServerPlugins} from '../../core/plugin';
import {Request, Response, NextFunction, Server} from '../../core/types.js';
import {ElementGraph} from '../../node/element-graph.js';
Expand Down Expand Up @@ -72,6 +75,7 @@ export async function createPreviewServer(options: {
server.use(sirv(publicDir, {dev: false}));

// Add the root.js preview server middlewares.
server.use(trailingSlashMiddleware({rootConfig}));
server.use(rootPreviewServerMiddleware());

// Add error handlers.
Expand Down
6 changes: 5 additions & 1 deletion packages/root/src/cli/commands/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@ import {dim} from 'kleur/colors';
import sirv from 'sirv';

import {RootConfig} from '../../core/config';
import {rootProjectMiddleware} from '../../core/middleware';
import {
rootProjectMiddleware,
trailingSlashMiddleware,
} from '../../core/middleware';
import {configureServerPlugins} from '../../core/plugin';
import {Request, Response, NextFunction, Server} from '../../core/types.js';
import {ElementGraph} from '../../node/element-graph';
Expand Down Expand Up @@ -67,6 +70,7 @@ export async function createProdServer(options: {
server.use(sirv(publicDir, {dev: false}));

// Add the root.js preview server middlewares.
server.use(trailingSlashMiddleware({rootConfig}));
server.use(rootProdServerMiddleware());

// Add error handlers.
Expand Down
11 changes: 11 additions & 0 deletions packages/root/src/core/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,17 @@ export interface RootServerConfig {
middlewares?: Array<
(req: Request, res: Response, next: NextFunction) => void | Promise<void>
>;

/**
* The `trailingSlash` config allows you to control how the server handles
* trailing slashes. This config only affects URLs that do not have a file
* extension (i.e. HTML paths).
*
* - When `true`, the server redirects URLs to add a trailing slash
* - When `false`, the server redirects URLs to remove a trailing slash
* - When unspecified, the server allows URLs with and without trailing slash
*/
trailingSlash?: boolean;
}

export function defineConfig(config: RootUserConfig): RootUserConfig {
Expand Down
72 changes: 72 additions & 0 deletions packages/root/src/core/middleware.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import path from 'node:path';
import {RootConfig} from './config';
import {Request, Response, NextFunction} from './types';

Expand All @@ -10,3 +11,74 @@ export function rootProjectMiddleware(options: {rootConfig: RootConfig}) {
next();
};
}

/**
* Trailing slash middleware. Handles trailing slash redirects (preserving any
* query params) using the `server.trailingSlash` config in root.config.ts.
*/
export function trailingSlashMiddleware(options: {rootConfig: RootConfig}) {
const trailingSlash = options.rootConfig.server?.trailingSlash;

return (req: Request, res: Response, next: NextFunction) => {
// If `trailingSlash: false`, force a trailing slash in the URL.
if (
trailingSlash === true &&
!path.extname(req.path) &&
!req.path.endsWith('/')
) {
const redirectPath = `${req.path}/`;
redirectWithQuery(req, res, 301, redirectPath);
return;
}

// If `trailingSlash: false`, remove any trailing slash from the URL.
if (
trailingSlash === false &&
!path.extname(req.path) &&
req.path !== '/' &&
req.path.endsWith('/')
) {
const redirectPath = removeTrailingSlashes(req.path);
redirectWithQuery(req, res, 301, redirectPath);
return;
}

next();
};
}

/**
* Issues an HTTP redirect, preserving any query params from the original req.
*/
function redirectWithQuery(
req: Request,
res: Response,
redirectCode: number,
redirectPath: string
) {
const queryStr = getQueryStr(req);
const redirectUrl = queryStr ? `${redirectPath}?${queryStr}` : redirectPath;
res.redirect(redirectCode, redirectUrl);
}

/**
* Returns the query string for a request, or empty string if no query.
*/
function getQueryStr(req: Request): string {
const qIndex = req.originalUrl.indexOf('?');
if (qIndex === -1) {
return '';
}
return req.originalUrl.slice(qIndex + 1);
}

/**
* Removes trailing slashes from a URL path.
* Note: A path with only slashes (e.g. `///`) returns `/`.
*/
function removeTrailingSlashes(urlPath: string) {
while (urlPath.endsWith('/') && urlPath !== '/') {
urlPath = urlPath.slice(0, -1);
}
return urlPath;
}

0 comments on commit c1dd173

Please sign in to comment.