diff --git a/.changeset/lazy-pans-fetch.md b/.changeset/lazy-pans-fetch.md new file mode 100644 index 00000000..8be8ccda --- /dev/null +++ b/.changeset/lazy-pans-fetch.md @@ -0,0 +1,5 @@ +--- +'@blinkk/root': patch +--- + +feat: add trailingSlash middleware diff --git a/packages/root/src/cli/commands/dev.ts b/packages/root/src/cli/commands/dev.ts index 0fc9abd9..940f0eed 100644 --- a/packages/root/src/cli/commands/dev.ts +++ b/packages/root/src/cli/commands/dev.ts @@ -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'; @@ -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()); diff --git a/packages/root/src/cli/commands/preview.ts b/packages/root/src/cli/commands/preview.ts index 606fd158..a73d762e 100644 --- a/packages/root/src/cli/commands/preview.ts +++ b/packages/root/src/cli/commands/preview.ts @@ -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'; @@ -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. diff --git a/packages/root/src/cli/commands/start.ts b/packages/root/src/cli/commands/start.ts index 6d99631a..b97e458f 100644 --- a/packages/root/src/cli/commands/start.ts +++ b/packages/root/src/cli/commands/start.ts @@ -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'; @@ -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. diff --git a/packages/root/src/core/config.ts b/packages/root/src/core/config.ts index 546f2d20..5b6d9b0e 100644 --- a/packages/root/src/core/config.ts +++ b/packages/root/src/core/config.ts @@ -118,6 +118,17 @@ export interface RootServerConfig { middlewares?: Array< (req: Request, res: Response, next: NextFunction) => void | Promise >; + + /** + * 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 { diff --git a/packages/root/src/core/middleware.ts b/packages/root/src/core/middleware.ts index 5fbfe5d3..5fd435bd 100644 --- a/packages/root/src/core/middleware.ts +++ b/packages/root/src/core/middleware.ts @@ -1,3 +1,4 @@ +import path from 'node:path'; import {RootConfig} from './config'; import {Request, Response, NextFunction} from './types'; @@ -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; +}