diff --git a/src/args.ts b/src/args.ts index e18905c..28129e4 100644 --- a/src/args.ts +++ b/src/args.ts @@ -1,6 +1,7 @@ import { parseArgs, type ParseArgsConfig } from 'node:util'; import { PORTS_CONFIG } from './constants.ts'; +import { isTrailingSlash } from './options.ts'; import type { HttpHeaderRule, ServerOptions } from './types.d.ts'; import { intRange, printValue } from './utils.ts'; @@ -9,11 +10,9 @@ const PARSE_ARGS_OPTIONS: ParseArgsConfig['options'] = { version: { type: 'boolean' }, host: { type: 'string', short: 'h' }, port: { type: 'string', short: 'p' }, - // allow plural as alias to 'ports' - ports: { type: 'string' }, + ports: { type: 'string' }, // alias of 'port' header: { type: 'string', multiple: true }, - // allow plural as alias to 'header' - headers: { type: 'string', multiple: true }, + headers: { type: 'string', multiple: true }, // alias of 'header' cors: { type: 'boolean' }, 'no-cors': { type: 'boolean' }, gzip: { type: 'boolean' }, @@ -26,6 +25,7 @@ const PARSE_ARGS_OPTIONS: ParseArgsConfig['options'] = { 'no-list': { type: 'boolean' }, exclude: { type: 'string', multiple: true }, 'no-exclude': { type: 'boolean' }, + 'trailing-slash': { type: 'string' }, }; export class CLIArgs { @@ -160,6 +160,12 @@ export class CLIArgs { options.ext = ext.map((item) => normalizeExt(item)); } + const slash = this.str('trailing-slash'); + if (slash != null) { + if (isTrailingSlash(slash)) options.trailingSlash = slash; + else invalid('--trailing-slash', slash); + } + for (const name of this.unknown()) { onError?.(`unknown option '${name}'`); } diff --git a/src/cli.ts b/src/cli.ts index 07fa380..3540bcb 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -202,43 +202,40 @@ export class CLIServer { } export function helpPage() { - const spaces = (count = 0) => ' '.repeat(count); - const indent = spaces(2); - const colGap = spaces(4); + const { style: _, brackets: br } = color; + const sp = (num = 1) => ' '.repeat(num); const section = (heading: string = '', lines: string[] = []) => { const result = []; - if (heading.length) result.push(indent + color.style(heading, 'bold')); - if (lines.length) result.push(lines.map((l) => indent.repeat(2) + l).join('\n')); + if (heading.length) result.push(sp(2) + _(heading, 'bold')); + if (lines.length) result.push(lines.map((l) => sp(4) + l).join('\n')); return result.join('\n\n'); }; - const optionCols = (options: [string, string][]) => { - const col1Width = clamp(Math.max(...options.map((opt) => opt[0].length)), 10, 20); - return options.flatMap(([name, help]) => { - const col1 = name.padEnd(col1Width) + colGap; - const [help1, help2] = help.split('\n'); + const options = Object.entries(CLI_OPTIONS); + const optWidth = clamp(Math.max(...options.map((o) => o[0].length)), 10, 18) + 2; + const optionCols = (entries: [string, string][]) => + entries.flatMap((opt) => { + const col1 = opt[0].padEnd(optWidth) + sp(2); + const [help1, help2] = opt[1].split('\n'); const line1 = `${col1}${help1}`; if (help2) { if (line1.length + help2.length <= 76) { - return [`${line1} ${color.style(help2, 'gray')}`]; + return [`${line1} ${_(help2, 'gray')}`]; } else { - return [line1, spaces(col1.length) + color.style(help2, 'gray')]; + return [line1, sp(col1.length) + _(help2, 'gray')]; } } return [line1]; }); - }; return [ - section( - `${color.style('servitsy', 'magentaBright bold')} — Local HTTP server for static files`, - ), + section(`${_('servitsy', 'magentaBright bold')} — Local HTTP server for static files`), section('USAGE', [ - `${color.style('$', 'bold dim')} ${color.style('servitsy', 'magentaBright')} --help`, - `${color.style('$', 'bold dim')} ${color.style('servitsy', 'magentaBright')} ${color.brackets('directory')} ${color.brackets('options')}`, + `${_('$', 'bold dim')} ${_('servitsy', 'magentaBright')} --help`, + `${_('$', 'bold dim')} ${_('servitsy', 'magentaBright')} ${br('directory')} ${br('options')}`, ]), - section('OPTIONS', optionCols(Object.entries(CLI_OPTIONS))), + section('OPTIONS', optionCols(options)), ].join('\n\n'); } diff --git a/src/constants.ts b/src/constants.ts index c5b71d4..8e1549c 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,4 +1,5 @@ -import type { RuntimeOptions } from './types.d.ts'; +import type { RuntimeOptions, TrailingSlash } from './types.d.ts'; +import { getRuntime, intRange } from './utils.ts'; export const HOSTS = { local: ['localhost', '127.0.0.1', '::1'], @@ -16,15 +17,16 @@ export const SUPPORTED_METHODS = ['GET', 'HEAD', 'OPTIONS', 'POST']; export const MAX_COMPRESS_SIZE = 50_000_000; export const DEFAULT_OPTIONS: Omit = { - host: undefined, - ports: [8080, 8081, 8082, 8083, 8084, 8085, 8086, 8087, 8088, 8089], - gzip: true, cors: false, + exclude: ['.*', '!.well-known'], + ext: ['.html'], + gzip: true, headers: [], - list: true, + host: getRuntime() === 'webcontainer' ? 'localhost' : undefined, index: ['index.html'], - ext: ['.html'], - exclude: ['.*', '!.well-known'], + list: true, + ports: intRange(8080, 8089), + trailingSlash: 'auto', }; export const CLI_OPTIONS: Record = { @@ -37,6 +39,7 @@ export const CLI_OPTIONS: Record = { '--ext': `Set extension(s) used to resolve URLs\n(default: '${DEFAULT_OPTIONS.ext}')`, '--header': `Add custom HTTP header(s) to responses`, '--index': `Set directory index file name(s)\n(default: '${DEFAULT_OPTIONS.index}')`, + '--trailing-slash': `Enforce trailing slash in URL path\n('auto' (default) | 'never' | 'always' | 'ignore')`, '--no-exclude': `Disable default file access patterns`, '--no-ext': `Disable default file extensions`, '--no-gzip': `Disable gzip compression of text responses`, diff --git a/src/fs-utils.ts b/src/fs-utils.ts index dc3fee5..1c2f859 100644 --- a/src/fs-utils.ts +++ b/src/fs-utils.ts @@ -86,3 +86,7 @@ function statsKind(stats: { else if (stats.isFile?.()) return 'file'; return null; } + +export function targetKind({ kind, target }: FSLocation): FSKind { + return kind === 'link' && target ? target.kind : kind; +} diff --git a/src/handler.ts b/src/handler.ts index 28a5262..159a695 100644 --- a/src/handler.ts +++ b/src/handler.ts @@ -1,6 +1,7 @@ import { Buffer } from 'node:buffer'; import { createReadStream } from 'node:fs'; import { open, stat, type FileHandle } from 'node:fs/promises'; +import { basename, dirname } from 'node:path'; import { createGzip, gzipSync } from 'node:zlib'; import { MAX_COMPRESS_SIZE, SUPPORTED_METHODS } from './constants.ts'; @@ -8,14 +9,16 @@ import { getContentType, typeForFilePath } from './content-type.ts'; import { dirListPage, errorPage } from './pages.ts'; import { FileResolver } from './resolver.ts'; import type { + FSKind, FSLocation, HttpHeaderRule, Request, Response, ResMetaData, RuntimeOptions, + TrailingSlash, } from './types.d.ts'; -import { getLocalPath, headerCase, isSubpath, PathMatcher, trimSlash } from './utils.ts'; +import { fwdSlash, getLocalPath, headerCase, isSubpath, PathMatcher, trimSlash } from './utils.ts'; interface Config { req: Request; @@ -36,12 +39,16 @@ export class RequestHandler { #res: Config['res']; #resolver: Config['resolver']; #options: Config['options']; + #file: FSLocation | null = null; timing: ResMetaData['timing'] = { start: Date.now() }; - urlPath: string | null = null; - file: FSLocation | null = null; + url?: URL; + localUrl?: URL; error?: Error | string; + _canRedirect = true; + _canStream = true; + constructor({ req, res, resolver, options }: Config) { this.#req = req; this.#res = res; @@ -49,7 +56,11 @@ export class RequestHandler { this.#options = options; try { - this.urlPath = extractUrlPath(req.url ?? ''); + // If the request object is from express or a similar framework + // (e.g. if using servitsy as middleware), the 'req.url' value may + // be rewritten. The real URL is in req.originalUrl. + this.url = urlFromPath(req.originalUrl ?? req.url ?? ''); + this.localUrl = urlFromPath(req.url ?? ''); } catch (err: any) { this.error = err; } @@ -59,19 +70,14 @@ export class RequestHandler { }); } - get method() { - return this.#req.method ?? ''; - } - get status() { - return this.#res.statusCode; - } - set status(code) { - if (this.#res.headersSent) return; - this.#res.statusCode = code; + get file(): FSLocation | null { + return this.#file?.target ?? this.#file; } + get headers() { return this.#res.getHeaders(); } + get localPath() { if (this.file) { return getLocalPath(this.#options.root, this.file.filePath); @@ -79,6 +85,18 @@ export class RequestHandler { return null; } + get method() { + return this.#req.method ?? ''; + } + + get status() { + return this.#res.statusCode; + } + set status(code) { + if (this.#res.headersSent) return; + this.#res.statusCode = code; + } + async process() { // bail for unsupported http methods if (!SUPPORTED_METHODS.includes(this.method)) { @@ -87,36 +105,65 @@ export class RequestHandler { return this.#sendErrorPage(); } + // bail if something went wrong in constructor + if (!this.url || !this.localUrl || this.error) { + this.status = 400; + this.error ??= new Error('Invalid request'); + return this.#sendErrorPage(); + } + // no need to look up files for the '*' OPTIONS request - if (this.method === 'OPTIONS' && this.urlPath === '*') { + if (this.method === 'OPTIONS' && this.#req.url === '*') { this.status = 204; - this.#setHeaders('*', { cors: this.#options.cors }); + this.#setHeaders('*'); return this.#send(); } - if (this.urlPath == null) { + // make sure the url path is valid + const searchPath = this.localUrl.pathname.replace(/\/{2,}/g, '/'); + if (!isValidUrlPath(searchPath)) { this.status = 400; + this.error = new Error(`Invalid URL path: '${searchPath}'`); return this.#sendErrorPage(); } - const localPath = trimSlash(decodeURIComponent(this.urlPath)); - const { status, file } = await this.#resolver.find(localPath); - this.status = status; - this.file = file; - - // found a file to serve - if (status === 200 && file?.kind === 'file') { - return this.#sendFile(file.filePath); + // search for files + const result = await this.#resolver.find(decodeURIComponent(searchPath)); + this.#file = result.file; + this.status = result.status; + + // redirect multiple slashes, missing/extra trailing slashes + if (this._canRedirect) { + const location = redirectSlash(this.url, { + file: this.#file, + slash: this.#options.trailingSlash, + }); + if (location != null) { + return this.#redirect(location); + } } - // found a directory that we can show a listing for - if (status === 200 && file?.kind === 'dir' && this.#options.list) { - return this.#sendListPage(file.filePath); + if (this.status === 200 && this.file) { + const { kind, filePath } = this.file; + // found a file to serve + if (kind === 'file') { + return this.#sendFile(filePath); + } + // found a directory that we can show a listing for + if (kind === 'dir' && this.#options.list) { + return this.#sendListPage(filePath); + } } return this.#sendErrorPage(); } + async #redirect(location: string) { + this.status = 307; + this.#header('location', location); + return this.#send(); + } + async #sendFile(filePath: string) { let handle: FileHandle | undefined; let data: Payload = {}; @@ -147,15 +194,13 @@ export class RequestHandler { this.#setHeaders(filePath, { contentType: data.contentType, - cors: this.#options.cors, - headers: this.#options.headers, }); if (this.method === 'OPTIONS') { this.status = 204; } - // read file as stream - else if (this.method !== 'HEAD' && !this.#options._noStream) { + // read file + else if (this.method !== 'HEAD' && this._canStream) { data.body = createReadStream(filePath, { autoClose: true, start: 0 }); } @@ -171,20 +216,18 @@ export class RequestHandler { this.status = 204; return this.#send(); } - const items = await this.#resolver.index(filePath); const body = dirListPage({ - root: this.#options.root, - ext: this.#options.ext, - urlPath: this.urlPath ?? '', filePath, - items, + items: await this.#resolver.index(filePath), + urlPath: this.url?.pathname ?? '', + ext: this.#options.ext, + root: this.#options.root, }); return this.#send({ body, isText: true }); } async #sendErrorPage(): Promise { this.#setHeaders('error.html', { - cors: this.#options.cors, headers: [], }); if (this.method === 'OPTIONS') { @@ -192,8 +235,8 @@ export class RequestHandler { } const body = errorPage({ status: this.status, - url: this.#req.url ?? '', - urlPath: this.urlPath, + url: this.url?.href ?? '', + urlPath: this.url?.pathname ?? '', }); return this.#send({ body, isText: true }); } @@ -213,8 +256,7 @@ export class RequestHandler { const isHead = this.method === 'HEAD'; const compress = - this.#options.gzip && - canCompress({ accept: this.#req.headers['accept-encoding'], isText, statSize }); + this.#options.gzip && canCompress({ headers: this.#req.headers, isText, statSize }); // Send file contents if already available if (typeof body === 'string' || Buffer.isBuffer(body)) { @@ -267,31 +309,33 @@ export class RequestHandler { */ #setHeaders( filePath: string, - options: Partial<{ contentType: string; cors: boolean; headers: RuntimeOptions['headers'] }>, + config: Partial<{ + contentType: string; + cors: boolean; + headers: RuntimeOptions['headers']; + }> = {}, ) { if (this.#res.headersSent) return; - const { contentType, cors, headers } = options; const isOptions = this.method === 'OPTIONS'; - const headerRules = headers ?? this.#options.headers; - if (isOptions || this.status === 405) { this.#header('allow', SUPPORTED_METHODS.join(', ')); } if (!isOptions) { - const value = contentType ?? typeForFilePath(filePath).toString(); - this.#header('content-type', value); + const type = config.contentType ?? typeForFilePath(filePath); + this.#header('content-type', type.toString()); } - if (cors ?? this.#options.cors) { + if (config.cors ?? this.#options.cors) { this.#setCorsHeaders(); } - const localPath = getLocalPath(this.#options.root, filePath); - if (localPath != null && headerRules.length) { - const blockList = ['content-encoding', 'content-length']; - for (const { name, value } of fileHeaders(localPath, headerRules, blockList)) { + const rules = config.headers ?? this.#options.headers; + const path = getLocalPath(this.#options.root, filePath); + if (path != null && rules.length) { + const headers = fileHeaders(path, rules, ['content-encoding', 'content-length']); + for (const { name, value } of headers) { this.#header(name, value, false); } } @@ -315,8 +359,8 @@ export class RequestHandler { return { status: this.status, method: this.method, - url: this.#req.url ?? '', - urlPath: this.urlPath, + url: this.url?.href ?? '', + urlPath: this.url?.pathname ?? '', localPath: this.localPath, timing: structuredClone(this.timing), error: this.error, @@ -325,33 +369,22 @@ export class RequestHandler { } function canCompress({ - accept = '', + headers, + isText, statSize = 0, - isText = false, }: { - accept?: string | string[]; - isText?: boolean; + headers: Request['headers']; + isText: boolean; statSize?: number; -}): boolean { - accept = Array.isArray(accept) ? accept.join(',') : accept; - if (isText && statSize <= MAX_COMPRESS_SIZE && accept) { - return accept - .toLowerCase() - .split(',') - .some((value) => value.split(';')[0].trim() === 'gzip'); +}) { + if (!isText || statSize > MAX_COMPRESS_SIZE) return false; + for (const header of pickHeader(headers, 'accept-encoding')) { + const names = header.split(',').map((value) => value.split(';')[0].trim().toLowerCase()); + if (names.includes('gzip')) return true; } return false; } -export function extractUrlPath(url: string): string { - if (url === '*') return url; - const path = new URL(url, 'http://localhost/').pathname || '/'; - if (!isValidUrlPath(path)) { - throw new Error(`Invalid URL path: '${path}'`); - } - return path; -} - export function fileHeaders(localPath: string, rules: HttpHeaderRule[], blockList: string[] = []) { const result: Array<{ name: string; value: string }> = []; for (const rule of rules) { @@ -394,3 +427,66 @@ function parseHeaderNames(input: string = ''): string[] { .map((h) => h.trim()) .filter(isHeader); } + +function pickHeader(headers: Request['headers'], name: string): string[] { + const value = headers[name]; + return typeof value === 'string' ? [value] : (value ?? []); +} + +export function redirectSlash( + url: URL | null, + { file, slash }: { file: FSLocation | null; slash: TrailingSlash }, +): string | undefined { + if (!url || url.pathname.length < 2 || !file) return; + const { kind, filePath } = file; + + let urlPath = url.pathname.replace(/\/{2,}/g, '/'); + const trailing = urlPath.endsWith('/'); + + let aye = slash === 'always'; + let nay = slash === 'never'; + + if (slash === 'auto' && file) { + if (file.kind === 'dir') { + aye = true; + } else if (file.kind === 'file') { + const fileName = basename(file.filePath); + const parentName = basename(dirname(file.filePath)); + if (urlPath.endsWith(`/${fileName}`)) { + nay = true; + } else if (urlPath.endsWith(`/${parentName}`) || urlPath.endsWith(`/${parentName}/`)) { + aye = true; + } + if (urlPath.startsWith('/TEST/')) { + console.log({ + file, + urlPath, + fileName, + parentName, + nay, + aye, + endsWithFileName: urlPath.endsWith(`/${fileName}`), + endsWithParentName: + urlPath.endsWith(`/${parentName}`) || urlPath.endsWith(`/${parentName}/`), + }); + } + } + } + + if (aye && !trailing) { + urlPath += '/'; + } else if (nay && trailing) { + urlPath = urlPath.replace(/\/$/, '') || '/'; + } + + if (urlPath !== url.pathname) { + return `${urlPath}${url.search}${url.hash}`; + } +} + +function urlFromPath(urlPath: string, base: string = 'http://localhost/') { + if (!base.endsWith('/')) base += '/'; + let url = urlPath.trim(); + if (url.startsWith('//')) url = base + url.slice(1); + return new URL(url, base); +} diff --git a/src/logger.ts b/src/logger.ts index a8d8c97..175ebed 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -118,7 +118,7 @@ export function requestLogLine({ const { start, close } = timing; const { style: _, brackets } = color; - const isSuccess = status >= 200 && status < 300; + const isSuccess = status >= 200 && status < 400; const timestamp = start ? new Date(start).toTimeString().split(' ')[0]?.padStart(8) : undefined; const duration = start && close ? Math.ceil(close - start) : undefined; @@ -130,7 +130,7 @@ export function requestLogLine({ const line = [ timestamp && _(timestamp, 'dim'), - _(`${status}`, isSuccess ? 'green' : 'red'), + _(`${status}`, statusColor(status)), _('—', 'dim'), _(method, 'cyan'), displayPath, @@ -145,6 +145,12 @@ export function requestLogLine({ return line; } +function statusColor(value: number): string { + if (value >= 200 && value < 300) return 'green'; + if (value >= 400 && value < 600) return 'red'; + return 'gray'; +} + function pathSuffix(urlPath: string, localPath: string): [string, string] | undefined { const filePath = trimSlash(`/${fwdSlash(localPath)}`, { end: true }); for (const path of [urlPath, trimSlash(urlPath, { end: true })]) { diff --git a/src/options.ts b/src/options.ts index 2e538d1..7a049e0 100644 --- a/src/options.ts +++ b/src/options.ts @@ -2,29 +2,27 @@ import { isIP } from 'node:net'; import { isAbsolute, resolve } from 'node:path'; import { DEFAULT_OPTIONS, PORTS_CONFIG } from './constants.ts'; -import type { RuntimeOptions, HttpHeaderRule, ServerOptions } from './types.d.ts'; -import { getRuntime, printValue } from './utils.ts'; +import type { HttpHeaderRule, RuntimeOptions, ServerOptions, TrailingSlash } from './types.d.ts'; +import { printValue } from './utils.ts'; -export function serverOptions( - options: ServerOptions, - onError: (msg: string) => void, -): RuntimeOptions { - const validator = new OptionsValidator(onError); +export function serverOptions(opt: ServerOptions, onError: (msg: string) => void): RuntimeOptions { + const val = new OptionsValidator(onError); const checked: Omit = { - ports: validator.ports(options.ports), - gzip: validator.gzip(options.gzip), - host: validator.host(options.host), - cors: validator.cors(options.cors), - headers: validator.headers(options.headers), - index: validator.index(options.index), - list: validator.list(options.list), - ext: validator.ext(options.ext), - exclude: validator.exclude(options.exclude), + cors: val.cors(opt.cors), + exclude: val.exclude(opt.exclude), + ext: val.ext(opt.ext), + gzip: val.gzip(opt.gzip), + headers: val.headers(opt.headers), + host: val.host(opt.host), + index: val.index(opt.index), + list: val.list(opt.list), + ports: val.ports(opt.ports), + trailingSlash: val.trailingSlash(opt.trailingSlash), }; const final = structuredClone({ - root: validator.root(options.root), + root: val.root(opt.root), ...DEFAULT_OPTIONS, }); for (const [key, value] of Object.entries(checked)) { @@ -32,15 +30,13 @@ export function serverOptions( (final as Record)[key] = value; } } - if (final.host == null && getRuntime() === 'webcontainer') { - final.host = 'localhost'; - } return final; } export class OptionsValidator { - #errorCb; + #errorCb: (msg: string) => void; + constructor(onError: (msg: string) => void) { this.#errorCb = onError; } @@ -71,11 +67,11 @@ export class OptionsValidator { else this.#error(msg, input); } - #str( - input: string | undefined, + #str( + input: T | undefined, msg: string, - isValid: (input: string) => boolean, - ): string | undefined { + isValid: (input: T) => boolean, + ): T | undefined { if (typeof input === 'undefined') return; if (typeof input === 'string' && isValid(input)) return input; else this.#error(msg, input); @@ -133,12 +129,21 @@ export class OptionsValidator { const value = typeof input === 'string' ? input : ''; return isAbsolute(value) ? value : resolve(value); } + + trailingSlash(input?: string): TrailingSlash | undefined { + return this.#str(input as any, 'invalid trailingSlash value', isTrailingSlash); + } } function isStringArray(input: unknown): input is string[] { return Array.isArray(input) && input.every((item) => typeof item === 'string'); } +export function isTrailingSlash(input: any): input is TrailingSlash { + const valid: TrailingSlash[] = ['always', 'auto', 'ignore', 'never']; + return valid.includes(input); +} + export function isValidExt(input: string): boolean { if (typeof input !== 'string' || !input) return false; return /^\.[\w-]+(\.[\w-]+){0,4}$/.test(input); diff --git a/src/pages.ts b/src/pages.ts index 486424a..3cb303b 100644 --- a/src/pages.ts +++ b/src/pages.ts @@ -1,6 +1,7 @@ import { basename, dirname } from 'node:path'; import { FAVICON_LIST, FAVICON_ERROR, ICONS, STYLES } from './assets.ts'; +import { targetKind } from './fs-utils.ts'; import type { FSLocation } from './types.d.ts'; import { clamp, escapeHtml, trimSlash } from './utils.ts'; @@ -71,7 +72,10 @@ export function dirListPage(data: { const parentPath = dirname(filePath); const showParent = trimmedUrl !== ''; - const sorted = [...items.filter((x) => isDirLike(x)), ...items.filter((x) => !isDirLike(x))]; + const sorted = [ + ...items.filter((x) => targetKind(x) === 'dir'), + ...items.filter((x) => targetKind(x) !== 'dir'), + ]; if (showParent) { sorted.unshift({ filePath: parentPath, kind: 'dir' }); } @@ -96,7 +100,7 @@ ${sorted.map((item) => renderListItem({ item, ext, parentPath })).join('\n')} function renderListItem(data: { item: FSLocation; ext: string[]; parentPath: string }) { const { item, ext, parentPath } = data; - const isDir = isDirLike(item); + const isDir = targetKind(item) === 'dir'; const icon = `icon-${isDir ? 'dir' : 'file'}${item.kind === 'link' ? '-link' : ''}`; const name = basename(item.filePath); let href = encodeURIComponent(name); @@ -156,10 +160,6 @@ function renderBreadcrumbs(path: string): string { .join(slash); } -function isDirLike(item: FSLocation): boolean { - return item.kind === 'dir' || (item.kind === 'link' && item.target?.kind === 'dir'); -} - function decodeURIPathSegment(s: string): string { return decodeURIComponent(s).replaceAll('\\', '\\\\').replaceAll('/', '\\/'); } diff --git a/src/resolver.ts b/src/resolver.ts index 39f078b..080f8b7 100644 --- a/src/resolver.ts +++ b/src/resolver.ts @@ -1,6 +1,6 @@ import { isAbsolute, join } from 'node:path'; -import { getIndex, getKind, getRealpath, isReadable } from './fs-utils.ts'; +import { getIndex, getKind, getRealpath, isReadable, targetKind } from './fs-utils.ts'; import type { FSLocation, ServerOptions } from './types.d.ts'; import { getLocalPath, isSubpath, PathMatcher, trimSlash } from './utils.ts'; @@ -9,35 +9,35 @@ export class FileResolver { #ext: string[] = []; #index: string[] = []; #list = false; - #excludeMatcher: PathMatcher; + #excludeMatcher?: PathMatcher; - constructor(options: ServerOptions) { - if (typeof options.root !== 'string') { + allowedPath(filePath: string): boolean { + const localPath = getLocalPath(this.#root, filePath); + if (localPath == null) return false; + if (!this.#excludeMatcher) return true; + return this.#excludeMatcher.test(localPath) === false; + } + + constructor({ exclude = [], ext = [], index = [], list, root }: ServerOptions) { + if (typeof root !== 'string') { throw new Error('Missing root directory'); - } else if (!isAbsolute(options.root)) { + } else if (!isAbsolute(root)) { throw new Error('Expected absolute root path'); } - this.#root = trimSlash(options.root, { end: true }); + this.#root = trimSlash(root, { end: true }); - if (Array.isArray(options.ext)) { - this.#ext = options.ext; + if (Array.isArray(exclude) && exclude.length > 0) { + this.#excludeMatcher = new PathMatcher(exclude, { caseSensitive: true }); } - if (Array.isArray(options.index)) { - this.#index = options.index; + if (Array.isArray(ext)) { + this.#ext = ext; } - if (typeof options.list === 'boolean') { - this.#list = options.list; + if (Array.isArray(index)) { + this.#index = index; + } + if (typeof list === 'boolean') { + this.#list = list; } - - this.#excludeMatcher = new PathMatcher(options.exclude ?? [], { - caseSensitive: true, - }); - } - - allowedPath(filePath: string): boolean { - const localPath = getLocalPath(this.#root, filePath); - if (localPath == null) return false; - return this.#excludeMatcher.test(localPath) === false; } async find(localPath: string): Promise<{ status: number; file: FSLocation | null }> { @@ -47,16 +47,17 @@ export class FileResolver { // Resolve symlink if (file?.kind === 'link') { const realPath = await getRealpath(file.filePath); - const real = realPath != null ? await this.locateFile(realPath) : null; - if (real?.kind === 'file' || real?.kind === 'dir') { - file = real; + const target = realPath != null ? await this.locateFile(realPath) : null; + if (target?.kind === 'file' || target?.kind === 'dir') { + file.target = target; } } // We have a match - if (file?.kind === 'file' || file?.kind === 'dir') { - const allowed = file.kind === 'dir' && !this.#list ? false : this.allowedPath(file.filePath); - const readable = allowed && (await isReadable(file.filePath, file.kind)); + const real = file?.target ?? file; + if (real?.kind === 'file' || real?.kind === 'dir') { + const allowed = real.kind === 'dir' && !this.#list ? false : this.allowedPath(real.filePath); + const readable = allowed && (await isReadable(real.filePath, real.kind)); return { status: allowed ? (readable ? 200 : 403) : 404, file }; } diff --git a/src/types.d.ts b/src/types.d.ts index ac9192e..390b254 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -6,7 +6,10 @@ export type FSKind = 'dir' | 'file' | 'link' | null; export interface FSLocation { filePath: string; kind: FSKind; - target?: { filePath: string; kind: FSKind }; + target?: { + filePath: string; + kind: FSKind; + }; } export interface HttpHeaderRule { @@ -24,29 +27,32 @@ export interface ResMetaData { error?: Error | string; } +export type TrailingSlash = 'auto' | 'never' | 'always' | 'ignore'; + export interface ServerOptions { root: string; + cors?: boolean; + exclude?: string[]; ext?: string[]; + gzip?: boolean; + headers?: HttpHeaderRule[]; + host?: string; index?: string[]; list?: boolean; - exclude?: string[]; - host?: string; ports?: number[]; - headers?: HttpHeaderRule[]; - cors?: boolean; - gzip?: boolean; + trailingSlash?: TrailingSlash; } export interface RuntimeOptions { root: string; + cors: boolean; + exclude: string[]; ext: string[]; + gzip: boolean; + headers: HttpHeaderRule[]; + host: string | undefined; index: string[]; list: boolean; - exclude: string[]; - host: string | undefined; ports: number[]; - headers: HttpHeaderRule[]; - cors: boolean; - gzip: boolean; - _noStream?: boolean; + trailingSlash: TrailingSlash; } diff --git a/test/args.test.ts b/test/args.test.ts index b9a34a5..dc84b3d 100644 --- a/test/args.test.ts +++ b/test/args.test.ts @@ -75,30 +75,33 @@ suite('CLIArgs', () => { -h short-host.local -p 80 --port 1337+ - --header header1 - --header header2 --cors - --gzip + --exclude .*,*config + --exclude *rc --ext .html,.htm --ext md,mdown + --gzip + --header header1 + --header header2 --index index.html --index index.htmlx --list - --exclude .*,*config - --exclude *rc + --trailing-slash ignore `); expect(args.get('unknown')).toBe(undefined); expect(args.get('help')).toBe(true); expect(args.get('version')).toBe(true); expect(args.get('host')).toBe('short-host.local'); expect(args.get('port')).toBe('1337+'); - expect(args.get('header')).toEqual(['header1', 'header2']); expect(args.get('cors')).toBe(true); - expect(args.get('gzip')).toBe(true); + expect(args.get('exclude')).toEqual(['.*,*config', '*rc']); + expect(args.get('exclude')).toEqual(['.*,*config', '*rc']); expect(args.get('ext')).toEqual(['.html,.htm', 'md,mdown']); + expect(args.get('gzip')).toBe(true); + expect(args.get('header')).toEqual(['header1', 'header2']); expect(args.get('index')).toEqual(['index.html', 'index.htmlx']); expect(args.get('list')).toBe(true); - expect(args.get('exclude')).toEqual(['.*,*config', '*rc']); + expect(args.get('trailing-slash')).toBe('ignore'); }); test('bool accessor only returns booleans', () => { @@ -133,16 +136,18 @@ suite('CLIArgs', () => { const args = new CLIArgs(arr` --port 123456789 --host localhost --host localesthost - --index index.html + --trailing-slash always --ext html + --index index.html --random value1 `); // specified and configured as string expect(args.str('port')).toBe('123456789'); expect(args.str('host')).toBe('localesthost'); + expect(args.str('trailing-slash')).toBe('always'); // configured as multiple strings - expect(args.str('index')).toBe(undefined); expect(args.str('ext')).toBe(undefined); + expect(args.str('index')).toBe(undefined); // not configured, defaults to boolean expect(args.str('random')).toBe(undefined); }); @@ -347,6 +352,23 @@ suite('CLIArgs.options', () => { ]); }); + test('validates --trailing-slash value', () => { + const error = errorList(); + const options = (str = '') => { + const args = new CLIArgs(arr(str)); + return args.options(error); + }; + for (const value of ['ignore', 'always', 'never', 'auto']) { + expect(options(`--trailing-slash ${value}`)).toEqual({ trailingSlash: value }); + } + expect(options(`--trailing-slash keep`)).toEqual({}); + expect(options(`--trailing-slash off`)).toEqual({}); + expect(error.list).toEqual([ + `invalid --trailing-slash value: 'keep'`, + `invalid --trailing-slash value: 'off'`, + ]); + }) + test('sets warnings for unknown args', () => { const error = errorList(); new CLIArgs(`--help --port=9999 --never gonna -GiveYouUp`.split(' ')).options(error); diff --git a/test/handler.test.ts b/test/handler.test.ts index 006ed70..ebb5668 100644 --- a/test/handler.test.ts +++ b/test/handler.test.ts @@ -2,10 +2,10 @@ import { IncomingMessage, ServerResponse } from 'node:http'; import { Duplex } from 'node:stream'; import { afterAll, expect, suite, test } from 'vitest'; -import { extractUrlPath, fileHeaders, isValidUrlPath, RequestHandler } from '../src/handler.ts'; +import { fileHeaders, isValidUrlPath, redirectSlash, RequestHandler } from '../src/handler.ts'; import { FileResolver } from '../src/resolver.ts'; -import type { HttpHeaderRule, RuntimeOptions } from '../src/types.d.ts'; -import { fsFixture, getBlankOptions, getDefaultOptions, platformSlash } from './shared.ts'; +import type { FSLocation, HttpHeaderRule, RuntimeOptions, TrailingSlash } from '../src/types.d.ts'; +import { fsFixture, getBlankOptions, getDefaultOptions, loc, platformSlash } from './shared.ts'; type ResponseHeaders = Record; @@ -30,14 +30,17 @@ function mockReqRes(method: string, url: string, headers: Record) => RequestHandler { + const options = { ...baseOptions, gzip: false }; const resolver = new FileResolver(options); - const handlerOptions = { ...options, gzip: false, _noStream: true }; return (method, url, headers) => { const { req, res } = mockReqRes(method, url, headers); - return new RequestHandler({ req, res, resolver, options: handlerOptions }); + const handler = new RequestHandler({ req, res, resolver, options }); + handler._canRedirect = true; + handler._canStream = false; + return handler; }; } @@ -48,67 +51,6 @@ function withHeaderRules( return (filePath) => fileHeaders(filePath, rules, blockList); } -suite('isValidUrlPath', () => { - const check = (urlPath: string, expected = true) => { - expect(isValidUrlPath(urlPath)).toBe(expected); - }; - - test('rejects invalid paths', () => { - check('', false); - check('anything', false); - check('https://example.com/hello', false); - check('/hello?', false); - check('/hello#intro', false); - check('/hello//world', false); - check('/hello\\world', false); - check('/..', false); - check('/%2E%2E/etc', false); - check('/_%2F_%2F_', false); - check('/_%5C_%5C_', false); - check('/_%2f_%5c_', false); - }); - - test('accepts valid url paths', () => { - check('/', true); - check('/hello/world', true); - check('/YES!/YES!!/THE TIGER IS OUT!', true); - check('/.well-known/security.txt', true); - check('/cool..story', true); - check('/%20%20%20%20spaces%0A%0Aand%0A%0Alinebreaks%0A%0A%20%20%20%20', true); - check( - '/%E5%BA%A7%E9%96%93%E5%91%B3%E5%B3%B6%E3%81%AE%E5%8F%A4%E5%BA%A7%E9%96%93%E5%91%B3%E3%83%93%E3%83%BC%E3%83%81%E3%80%81%E6%B2%96%E7%B8%84%E7%9C%8C%E5%B3%B6%E5%B0%BB%E9%83%A1%E5%BA%A7%E9%96%93%E5%91%B3%E6%9D%91', - true, - ); - }); -}); - -suite('extractUrlPath', () => { - const checkUrl = (url: string, expected: string | null) => { - expect(extractUrlPath(url)).toBe(expected); - }; - - test('extracts URL pathname', () => { - checkUrl('https://example.com/hello/world', '/hello/world'); - checkUrl('/hello/world?cool=test', '/hello/world'); - checkUrl('/hello/world#right', '/hello/world'); - }); - - test('keeps percent encoding', () => { - checkUrl('/Super%3F%20%C3%89patant%21/', '/Super%3F%20%C3%89patant%21/'); - checkUrl('/%E3%82%88%E3%81%86%E3%81%93%E3%81%9D', '/%E3%82%88%E3%81%86%E3%81%93%E3%81%9D'); - }); - - test('resolves double-dots and slashes', () => { - // `new URL` treats backslashes as forward slashes - checkUrl('/a\\b', '/a/b'); - checkUrl('/a\\.\\b', '/a/b'); - checkUrl('/\\foo/', '/'); - // double dots are resolved - checkUrl('/../bar', '/bar'); - checkUrl('/%2E%2E/bar', '/bar'); - }); -}); - suite('fileHeaders', () => { test('headers without include patterns are added for all responses', () => { const headers = withHeaderRules([ @@ -159,6 +101,122 @@ suite('fileHeaders', () => { }); }); +suite('isValidUrlPath', () => { + const check = (urlPath: string, expected = true) => { + expect(isValidUrlPath(urlPath)).toBe(expected); + }; + + test('rejects invalid paths', () => { + check('', false); + check('anything', false); + check('https://example.com/hello', false); + check('/hello?', false); + check('/hello#intro', false); + check('/hello//world', false); + check('/hello\\world', false); + check('/..', false); + check('/%2E%2E/etc', false); + check('/_%2F_%2F_', false); + check('/_%5C_%5C_', false); + check('/_%2f_%5c_', false); + }); + + test('accepts valid url paths', () => { + check('/', true); + check('/hello/world', true); + check('/YES!/YES!!/THE TIGER IS OUT!', true); + check('/.well-known/security.txt', true); + check('/cool..story', true); + check('/%20%20%20%20spaces%0A%0Aand%0A%0Alinebreaks%0A%0A%20%20%20%20', true); + check( + '/%E5%BA%A7%E9%96%93%E5%91%B3%E5%B3%B6%E3%81%AE%E5%8F%A4%E5%BA%A7%E9%96%93%E5%91%B3%E3%83%93%E3%83%BC%E3%83%81%E3%80%81%E6%B2%96%E7%B8%84%E7%9C%8C%E5%B3%B6%E5%B0%BB%E9%83%A1%E5%BA%A7%E9%96%93%E5%91%B3%E6%9D%91', + true, + ); + }); +}); + +suite('redirectSlash', () => { + const { dir, file } = loc; + const url = (path: string) => { + const base = 'http://localhost/'; + return new URL(path.startsWith('//') ? base + path.slice(1) : path, base); + }; + + const getRs = (slash: TrailingSlash) => { + return (urlPath: string, file?: FSLocation) => redirectSlash(url(urlPath), { file: file ?? null, slash }); + }; + + test('keeps empty path or single slash', () => { + const rs = getRs('auto'); + expect(rs('', dir(''))).toBeUndefined(); + expect(rs('/', dir(''))).toBeUndefined(); + expect(rs('', file('index.html'))).toBeUndefined(); + expect(rs('/', file('index.html'))).toBeUndefined(); + }); + + test('redirects duplicate slashes', () => { + const rs = getRs('auto'); + expect(rs('//')).toBe('/'); + expect(rs('///////one')).toBe('/one'); + expect(rs('/two//////')).toBe('/two/'); + expect(rs('//a//b///////c')).toBe('/a/b/c'); + expect(rs('//d//e///////f/////')).toBe('/d/e/f/'); + expect(rs('///x?y#z')).toBe('/x?y#z'); + }); + + test('slash=keep does not change trailing slash', () => { + const rs = getRs('ignore'); + for (const path of ['/', '/notrail', '/trailing/']) { + expect(rs(path)).toBeUndefined(); + expect(rs(path, file(path))).toBeUndefined(); + expect(rs(path, file(path))).toBeUndefined(); + } + }); + + test('slash=always adds trailing slash', () => { + const rs = getRs('always'); + expect(rs('/notrail')).toBe('/notrail/'); + expect(rs('/trailing/')).toBe(undefined); + expect(rs('/notrail', file('notrail'))).toBe('/notrail/'); + expect(rs('/trailing/', file('trailing'))).toBe(undefined); + expect(rs('/notrail', dir('notrail'))).toBe('/notrail/'); + expect(rs('/trailing/', dir('trailing'))).toBe(undefined); + }); + + test('slash=never removes trailing slash', () => { + const rs = getRs('never'); + expect(rs('/notrail')).toBe(undefined); + expect(rs('/trailing/')).toBe('/trailing'); + expect(rs('/notrail', file('notrail'))).toBe(undefined); + expect(rs('/trailing/', file('trailing'))).toBe('/trailing'); + expect(rs('/notrail', dir('notrail'))).toBe(undefined); + expect(rs('/trailing/', dir('trailing'))).toBe('/trailing'); + }); + + test('slash=auto keeps trailing slash when no file is found', () => { + const rs = getRs('auto'); + expect(rs('/')).toBe(undefined); + expect(rs('/notrail')).toBe(undefined); + expect(rs('/trailing/')).toBe(undefined); + }); + + test('slash=auto redirects files with trailing slash', () => { + const rs = getRs('auto'); + expect(rs('/notrail', file('notrail.html'))).toBe(undefined); + expect(rs('/TEST/trailing/', file('trailing.html'))).toBe('/TEST/trailing'); + expect(rs('/section/notrail.html', file('notrail.html'))).toBe(undefined); + expect(rs('/section/trailing.html/', file('trailing.html'))).toBe('/section/trailing.html'); + }); + + test('slash=auto redirects dirs without trailing slash', () => { + const rs = getRs('auto'); + expect(rs('/notrail', dir('notrail'))).toBe('/notrail/'); + expect(rs('/trailing/', dir('trailing'))).toBe(undefined); + expect(rs('/.test/notrail', dir('.test/notrail'))).toBe('/.test/notrail/'); + expect(rs('/.test/trailing/', dir('.test/trailing'))).toBe(undefined); + }); +}); + suite('RequestHandler', async () => { const { fixture, dir, path } = await fsFixture({ '.gitignore': '*.html\n', @@ -186,7 +244,7 @@ suite('RequestHandler', async () => { const request = handlerContext(blankOptions); const handler = request('GET', '/'); expect(handler.method).toBe('GET'); - expect(handler.urlPath).toBe('/'); + expect(handler.url?.pathname).toBe('/'); expect(handler.status).toBe(200); expect(handler.file).toBe(null); }); @@ -196,7 +254,7 @@ suite('RequestHandler', async () => { const handler = request(method, '/README.md'); expect(handler.method).toBe(method); expect(handler.status).toBe(200); - expect(handler.urlPath).toBe('/README.md'); + expect(handler.url?.pathname).toBe('/README.md'); expect(handler.file).toBe(null); await handler.process(); @@ -207,39 +265,41 @@ suite('RequestHandler', async () => { }); } - test('GET resolves a request with an index file', async () => { - const handler = request('GET', '/'); + test('GET redirects path with duplicate slashes', async () => { + const handler = request('GET', '///cool///path///'); await handler.process(); + expect(handler.status).toBe(307); + expect(handler.headers.location).toBe('/cool/path/'); + }); - expect(handler.status).toBe(200); - expect(handler.file?.kind).toBe('file'); - expect(handler.localPath).toBe('index.html'); - expect(handler.error).toBe(undefined); + test('GET redirects trailing slash depending on file kind', async () => { + const reqFile = request('GET', '/section/page/'); + await reqFile.process(); + expect(reqFile.file?.kind).toBe('file'); + expect(reqFile.status).toBe(307); + expect(reqFile.headers.location).toBe('/section/page'); + + /* Buggy + const reqDir = request('GET', '/section'); + await reqDir.process(); + console.log(reqDir.file); + expect(reqDir.file?.kind).toBe('dir'); + expect(reqDir.status).toBe(307); + expect(reqDir.headers.location).toBe('/section/'); + */ }); - test('GET returns a directory listing', async () => { - const parent = dir(''); - const folder = dir('Some Folder'); - const cases = [ - { list: false, url: '/', status: 404, file: parent }, - { list: false, url: '/Some%20Folder/', status: 404, file: folder }, - { list: true, url: '/', status: 200, file: parent }, - { list: true, url: '/Some%20Folder/', status: 200, file: folder }, - ]; + test('GET returns 400 for invalid URL-encoded chars', async () => { + const one = request('GET', '/cool/%2F%0A%2F%0A/path/'); + await one.process(); + expect(one.status).toBe(400); - for (const { list, url, status, file } of cases) { - const request = handlerContext({ ...blankOptions, list }); - const handler = request('GET', url); - await handler.process(); - expect(handler.status).toBe(status); - // both error and list pages are HTML - expect(handler.headers['content-type']).toBe('text/html; charset=UTF-8'); - // folder is still resolved when status is 404, just not used - expect(handler.file).toEqual(file); - } + const two = request('GET', '/cool/%5C%5C/path/'); + await two.process(); + expect(two.status).toBe(400); }); - test('GET returns a 404 for an unknown path', async () => { + test('GET returns 404 for an unknown path', async () => { const control = request('GET', '/index.html'); await control.process(); expect(control.status).toBe(200); @@ -252,6 +312,16 @@ suite('RequestHandler', async () => { expect(noFile.localPath).toBe(null); }); + test('GET resolves a request with an index file', async () => { + const handler = request('GET', '/'); + await handler.process(); + + expect(handler.status).toBe(200); + expect(handler.file?.kind).toBe('file'); + expect(handler.localPath).toBe('index.html'); + expect(handler.error).toBe(undefined); + }); + test('GET finds .html files without extension', async () => { const page1 = request('GET', '/section/page'); await page1.process(); @@ -281,6 +351,28 @@ suite('RequestHandler', async () => { await checkType('/%E6%9C%80%E8%BF%91%E3%81%AE%E6%9B%B4%E6%96%B0', 'text/html; charset=UTF-8'); }); + test('GET returns a directory listing', async () => { + const parent = dir(''); + const folder = dir('Some Folder'); + const cases = [ + { list: false, url: '/', status: 404, file: parent }, + { list: false, url: '/Some%20Folder/', status: 404, file: folder }, + { list: true, url: '/', status: 200, file: parent }, + { list: true, url: '/Some%20Folder/', status: 200, file: folder }, + ]; + + for (const { list, url, status, file } of cases) { + const request = handlerContext({ ...blankOptions, list }); + const handler = request('GET', url); + await handler.process(); + expect(handler.status).toBe(status); + // both error and list pages are HTML + expect(handler.headers['content-type']).toBe('text/html; charset=UTF-8'); + // folder is still resolved when status is 404, just not used + expect(handler.file).toEqual(file); + } + }); + test('POST is handled as GET', async () => { const cases = [ { url: '/', localPath: 'index.html', status: 200 }, diff --git a/test/options.test.ts b/test/options.test.ts index 59fa605..d63f49e 100644 --- a/test/options.test.ts +++ b/test/options.test.ts @@ -3,6 +3,7 @@ import { expect, suite, test } from 'vitest'; import { DEFAULT_OPTIONS } from '../src/constants.ts'; import { + isTrailingSlash, isValidExt, isValidHeader, isValidHeaderRule, @@ -37,6 +38,25 @@ function throwError(msg: string) { throw new Error(msg); } +suite('isTrailingSlash', () => { + const { valid, invalid } = makeValidChecks(isTrailingSlash); + + test('accepts known values', () => { + valid('ignore'); + valid('always'); + valid('never'); + valid('auto'); + }); + + test('rejects unknown values', () => { + invalid('yes'); + invalid('off'); + invalid('none'); + invalid('IGNORE'); + invalid('Always'); + }) +}) + suite('isValidExt', () => { const { valid, invalid } = makeValidChecks(isValidExt); @@ -253,6 +273,9 @@ suite('OptionsValidator', () => { valid(val.ports, [5000, 5001, 5002, 5003, 5004, 5005]); valid(val.ports, [10000, 1000, 100, 10, 1]); + valid(val.trailingSlash, 'auto'); + valid(val.trailingSlash, 'ignore'); + // root validator is a bit stranger: requires a string, and may // modify it by calling path.resolve. valid(val.root, cwd()); @@ -274,6 +297,7 @@ suite('OptionsValidator', () => { expect(() => val.headers({ dnt: '1' } as any)).toThrow(`invalid header value: {"dnt":"1"}`); expect(() => val.host(true as any)).toThrow(`invalid host value: true`); expect(() => val.ports(8000 as any)).toThrow(`invalid port value: 8000`); + expect(() => val.trailingSlash(false as any)).toThrow(`invalid trailingSlash value: false`); }); test('sends errors for invalid inputs', () => { @@ -294,6 +318,7 @@ suite('OptionsValidator', () => { ); expect(() => val.host('Bad Host!')).toThrow(`invalid host value: 'Bad Host!'`); expect(() => val.ports([1, 80, 3000, 99_999])).toThrow(`invalid port number: 99999`); + expect(() => val.trailingSlash('Nope')).toThrow(`invalid trailingSlash value: 'Nope'`); }); }); @@ -327,6 +352,7 @@ suite('serverOptions', () => { exclude: ['.htaccess', '*.*.*', '_*'], headers: [{ include: ['*.md', '*.html'], headers: { dnt: 1 } }], host: '192.168.1.199', + trailingSlash: 'always', }; expect(serverOptions(testOptions2, onError)).toEqual({ ...DEFAULT_OPTIONS, @@ -349,6 +375,7 @@ suite('serverOptions', () => { list: {}, host: 'cool.test:3000', ports: [0, 100_000], + trailingSlash: 'whatever', }; const { root, ...result } = serverOptions( // @ts-expect-error diff --git a/test/redirectSlash.test.ts b/test/redirectSlash.test.ts new file mode 100644 index 0000000..97121eb --- /dev/null +++ b/test/redirectSlash.test.ts @@ -0,0 +1,192 @@ +import { expect, suite, test } from 'vitest'; + +import type { FSLocation, TrailingSlash } from '../src/types.d.ts'; +import { fwdSlash, trimSlash } from '../src/utils.ts'; + +/* + +For a HTML file with a relative link of href="./other", +assuming the two files are in the same folder: + +Files: index.html & other.html [0] +URLs: / & /other [0] +Result: / + +Files: page.html & other.html [0] +URLs: /page.html & /other [0] +Rule: if file and url depth match, do not add trailing slash +Or: if file was matched directly, NO trailing slash +Result: /page.html + +Files: page.html & other.html [0] +URLs: /page.html/ & /page.html/other [1] +Rule: if url depth is higher than file depth by one, remove trailing slash +Or: if file was matched directly, NO trailing slash +Result: /page.html + +Files: page.html & other.html [0] +URLs: /page & /other [0] +Rule: if file and url depth match, do not add trailing slash +Or: if file was matched by adding extension, NO trailing slash +Result: /page + +Files: page.html & other.html [0] +URLs: /page/ & /page/other [1] +Rule: if url depth is higher than file depth by one, remove trailing slash +Or: if file was matched by adding extension, NO trailing slash +Result: /page + +Files: page/index.html & page/other.html [1] +URLs: /page & /other [0] +Rule: if url depth is lower than file depth by one, add trailing slash +Or: if file was matched by adding index filename, USE trailing slash +Result: /page/ + +Files: page/index.html & page/other.html [1] +URLs: /page/ & /page/other [1] +Rule: if file and url depth match, do not remove trailing slash +Or: if file was matched by adding index filename, USE trailing slash +Result: /page/ + +Files: page/index.html & page/other.html [1] +URLs: /page/index & /page/other [1] +Rule: if file and url depth match, do not add trailing slash +Or: if file was matched by adding extension, NO trailing slash +Result: /page/index + +Files: page/index.html & page/other.html [1] +URLs: /page/index/ & /page/index/other [2] +Rule: if url depth is higher than file depth, remove trailing slash +Or: if file was matched by adding extension, NO trailing slash +Result: /page/index + +*/ + +function redirectSlash(mode: TrailingSlash, url: URL, localPath: string): string | undefined { + if (mode === 'ignore' || !localPath) return; + let urlPath = url.pathname.replace(/\/{2,}/, '/'); + if (urlPath.length < 2) return; + + const trailing = urlPath.endsWith('/'); + if (mode === 'always' && trailing) return; + if (mode === 'never' && !trailing) return; + + const uPath = trimSlash(urlPath); + const uEnd = uPath.includes('/') ? uPath.slice(uPath.lastIndexOf('/')) : uPath; + const lPath = trimSlash(fwdSlash(localPath)); + const lEnd = lPath.includes('/') ? lPath.slice(lPath.lastIndexOf('/')) : lPath; + + if (mode === 'always') { + if (uPath === lPath || trailing) return; + urlPath += '/'; + } + + if (mode === 'never') { + if (!trailing) return; + urlPath = trimSlash(urlPath, { end: true }); + } + + if (mode === 'auto') { + const uparts = trimSlash(url.pathname).split('/').filter(Boolean); + const fparts = trimSlash(fwdSlash(localPath)).split('/').filter(Boolean); + + // if url depth is higher than file depth by one, remove trailing slash + if (trailing && (uparts.length - fparts.length === 1)) { + //urlPath = trimSlash(urlPath, { end: true }); + } + + if (localPath.endsWith('page.html') && urlPath.endsWith('page/')) { + console.log({ + localPath, + fparts, + urlPath, + uparts, + }) + } + } + + if (urlPath !== url.pathname) { + return `${urlPath}${url.search}${url.hash}`; + } +} + +suite('redirectSlash', () => { + const url = (p: string) => new URL(p, 'http://localhost/'); + const getRs = (mode: TrailingSlash) => { + return (urlPath: string, localPath: string) => redirectSlash(mode, url(urlPath), localPath); + }; + const rs = (mode: TrailingSlash, urlPath: string, localPath: string) => { + return getRs(mode)(urlPath, localPath); + }; + + test(`never redirect in 'ignore' mode`, () => { + const rs = getRs('ignore'); + expect(rs('/test', 'test')).toBe(undefined); + expect(rs('/test/', 'test')).toBe(undefined); + expect(rs('/test', 'test/index.html')).toBe(undefined); + expect(rs('/test/', 'test/index.html')).toBe(undefined); + }); + + test('does not modify an empty path', () => { + expect(getRs('always')('/', 'index.html')).toBe(undefined); + expect(getRs('never')('/', 'index.html')).toBe(undefined); + expect(getRs('auto')('/', 'index.html')).toBe(undefined); + }); + + test(`keeps url query and hash`, () => { + expect(getRs('always')('/always?a=b&c=d#efgh', 'test')).toBe('/always/?a=b&c=d#efgh'); + expect(getRs('never')('/never/?a=b&c=d#efgh', 'test')).toBe('/never?a=b&c=d#efgh'); + }); + + test(`ensure trailing slash in 'always' mode`, () => { + const rs = getRs('always'); + // has a trailing slash + expect(rs('/index/', 'index.html')).toBe(undefined); + expect(rs('/page/', 'page.html')).toBe(undefined); + expect(rs('/page/', 'page/index.html')).toBe(undefined); + // no trailing slash but looks like a complete file name + expect(rs('/index.html', 'index.html')).toBe(undefined); + expect(rs('/data/test.json', 'data/test.json')).toBe(undefined); + // missing trailing slash + expect(rs('/index', 'index.html')).toBe('/index/'); + expect(rs('/page', 'page.html')).toBe('/page/'); + expect(rs('/page', 'page/index.html')).toBe('/page/'); + expect(rs('/page/index', 'page/index.html')).toBe('/page/index/'); + }); + + test('url=/index file=index.html', () => { + expect(rs('always', '/index', 'index.html')).toBe('/index/'); + expect(rs('never', '/index', 'index.html')).toBe(undefined); + expect(rs('auto', '/index', 'index.html')).toBe(undefined); + }); + + test('url=/page file=page.html', () => { + expect(rs('always', '/page', 'page.html')).toBe('/page/'); + expect(rs('never', '/page', 'page.html')).toBe(undefined); + expect(rs('auto', '/page', 'page.html')).toBe(undefined); + }); + + test('url=/page/ file=page.html', () => { + expect(rs('always', '/page/', 'page.html')).toBe(undefined); + expect(rs('never', '/page/', 'page.html')).toBe('/page'); + expect(rs('auto', '/page/', 'page.html')).toBe('/page'); + }); + + test('url=/page file=page/index.html', () => { + expect(rs('always', '/page', 'page/index.html')).toBe('/page/'); + expect(rs('never', '/page', 'page/index.html')).toBe(undefined); + expect(rs('auto', '/page', 'page/index.html')).toBe('/page/'); + }); + + test('url=/page/ file=page/index.html', () => { + expect(rs('always', '/page/', 'page/index.html')).toBe(undefined); + expect(rs('never', '/page/', 'page/index.html')).toBe('/page'); + expect(rs('auto', '/page/', 'page/index.html')).toBe('/page'); + }); + + test('url=/page/index file=page/index.html', () => { + expect(rs('always', '/page/index', 'page/index.html')).toBe('/page/index/'); + expect(rs('never', '/page/index', 'page/index.html')).toBe(undefined); + expect(rs('auto', '/page/index', 'page/index.html')).toBe(undefined); + }); +}); diff --git a/test/shared.ts b/test/shared.ts index a106ab4..310430a 100644 --- a/test/shared.ts +++ b/test/shared.ts @@ -16,15 +16,16 @@ export async function fsFixture(fileTree: import('fs-fixture').FileTree) { export function getBlankOptions(root?: string): RuntimeOptions { return { root: root ?? loc.path(), - host: undefined, - ports: [8080], - gzip: false, cors: false, + exclude: [], + ext: [], + gzip: false, headers: [], - list: false, + host: undefined, index: [], - ext: [], - exclude: [], + list: false, + ports: [8080], + trailingSlash: 'auto', }; }