diff --git a/src/cli.ts b/src/cli.ts index 9fb592e..0382229 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -222,7 +222,7 @@ export function helpPage() { const options = optionsOrder.map((key) => CLI_OPTIONS[key]); const section = (heading: string = '', lines: string[] = []) => { - const result = []; + const result: string[] = []; if (heading.length) result.push(indent + color.style(heading, 'bold')); if (lines.length) result.push(lines.map((l) => indent.repeat(2) + l).join('\n')); return result.join('\n\n'); diff --git a/src/handler.ts b/src/handler.ts index 5942c37..2176bf1 100644 --- a/src/handler.ts +++ b/src/handler.ts @@ -175,6 +175,7 @@ export class RequestHandler { const body = dirListPage({ root: this.#options.root, ext: this.#options.ext, + urlMount: urlMountPath(this.#req), urlPath: this.urlPath ?? '', filePath, items, @@ -393,3 +394,18 @@ function parseHeaderNames(input: string = ''): string[] { .map((h) => h.trim()) .filter(isHeader); } + +export function urlMountPath({ + baseUrl, + originalUrl, + url, +}: Pick): string | undefined { + const trim = (p = '') => (p.length > 1 ? trimSlash(p, { end: true }) : p); + if (typeof baseUrl === 'string') { + return trim(baseUrl); + } else if (typeof url === 'string' && typeof originalUrl === 'string') { + if (url === '' || url === '/') return trim(originalUrl); + const lastIndex = originalUrl.lastIndexOf(url); + if (lastIndex > 0) return trim(originalUrl.slice(0, lastIndex)); + } +} diff --git a/src/index.ts b/src/index.ts index e69de29..a6fb620 100644 --- a/src/index.ts +++ b/src/index.ts @@ -0,0 +1,39 @@ +import { RequestHandler } from './handler.ts'; +import { serverOptions } from './options.ts'; +import { FileResolver } from './resolver.ts'; +import type { Request, Response, ServerOptions } from './types.d.ts'; +import { errorList } from './utils.ts'; + +export function middleware(options: Partial) { + if (!options || typeof options !== 'object') { + options = {}; + } + + const onError = errorList(); + const fullOptions = serverOptions( + { root: typeof options.root === 'string' ? options.root : '', ...options }, + onError, + ); + if (onError.list.length) { + throw new OptionsError(onError.list); + } + + const resolver = new FileResolver(fullOptions); + + return async function servitsyHandler(req: Request, res: Response, next: (value: any) => void) { + const handler = new RequestHandler({ req, res, resolver, options: fullOptions }); + await handler.process(); + if (res.statusCode !== 200 && !res.headersSent && typeof next === 'function') { + next(handler.data()); + } + }; +} + +class OptionsError extends Error { + list: string[]; + constructor(list: string[]) { + const message = 'Invalid option(s):\n' + list.map((msg) => ` ${msg}`).join('\n'); + super(message); + this.list = [...list]; + } +} diff --git a/src/pages.ts b/src/pages.ts index ab9c48f..7d7965a 100644 --- a/src/pages.ts +++ b/src/pages.ts @@ -57,17 +57,26 @@ export function errorPage(data: { status: number; url: string; urlPath: string | export function dirListPage(data: { root: string; + urlMount?: string; urlPath: string; filePath: string; items: FSLocation[]; ext: string[]; }) { - const { root, urlPath, filePath, items, ext } = data; + const { root, urlMount, urlPath, filePath, items, ext } = data; + const rootName = basename(root); - const trimmedUrl = trimSlash(urlPath); + const trimmedUrl = [urlMount, urlPath] + .map((s) => s && trimSlash(s)) + .filter(Boolean) + .join('/'); const baseUrl = trimmedUrl ? `/${trimmedUrl}/` : '/'; - const displayPath = decodeURIPathSegments(trimmedUrl ? `${rootName}/${trimmedUrl}` : rootName); + let displayPath = decodeURIPathSegments(trimmedUrl ? `${rootName}/${trimmedUrl}` : rootName); + if (urlMount && urlMount.length > 1) { + displayPath = decodeURIPathSegments(trimmedUrl); + } + const parentPath = dirname(filePath); const showParent = trimmedUrl !== ''; diff --git a/src/types.d.ts b/src/types.d.ts index 708d50f..e10fae3 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -1,5 +1,11 @@ -export type Request = import('node:http').IncomingMessage & { originalUrl?: string }; -export type Response = import('node:http').ServerResponse; +import type { IncomingMessage, ServerResponse } from 'node:http'; + +export type Request = IncomingMessage & { + baseUrl?: string; + originalUrl?: string; +}; + +export type Response = ServerResponse; export type FSKind = 'dir' | 'file' | 'link' | null; diff --git a/test/handler.test.ts b/test/handler.test.ts index 7a08920..df46ef8 100644 --- a/test/handler.test.ts +++ b/test/handler.test.ts @@ -2,7 +2,13 @@ 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 { + extractUrlPath, + fileHeaders, + isValidUrlPath, + RequestHandler, + urlMountPath, +} from '../src/handler.ts'; import { FileResolver } from '../src/resolver.ts'; import type { HttpHeaderRule, ServerOptions } from '../src/types.d.ts'; import { fsFixture, getBlankOptions, getDefaultOptions, platformSlash } from './shared.ts'; @@ -418,3 +424,21 @@ suite('RequestHandler', async () => { }); }); }); + +suite('urlMountPath', () => { + test('is undefined when baseUrl and originalUrl are undefined', () => { + expect(urlMountPath({})).toBe(undefined); + expect(urlMountPath({ url: '/hello/world' })).toBe(undefined); + }); + + test('returns the baseUrl when present (express 4+)', () => { + expect(urlMountPath({ baseUrl: '/' })).toBe('/'); + expect(urlMountPath({ baseUrl: '/hello' })).toBe('/hello'); + expect(urlMountPath({ baseUrl: '/hello/' })).toBe('/hello'); + }); + + test('infers the baseUrl from url and originalUrl (express 3)', () => { + expect(urlMountPath({ originalUrl: '/', url: '/' })).toBe('/'); + expect(urlMountPath({ originalUrl: '/static/index.html', url: '/index.html' })).toBe('/static'); + }); +});