Comparing version 0.4.3 to 0.4.4
import { CLI_OPTIONS, PORTS_CONFIG } from './constants.js'; | ||
import { intRange } from './utils.js'; | ||
/** | ||
@typedef {import('./types.d.ts').HttpHeaderRule} HttpHeaderRule | ||
@typedef {import('./types.d.ts').OptionSpec} OptionSpec | ||
@typedef {import('./types.d.ts').ServerOptions} ServerOptions | ||
*/ | ||
export class CLIArgs { | ||
/** @type {Array<[string, string]>} */ | ||
#map = []; | ||
/** @type {string[]} */ | ||
#list = []; | ||
/** @type {(keys: string | string[]) => (entry: [string, string]) => boolean} */ | ||
#mapFilter(keys) { | ||
return (entry) => (typeof keys === 'string' ? keys === entry[0] : keys.includes(entry[0])); | ||
} | ||
/** @param {string[]} args */ | ||
constructor(args) { | ||
@@ -45,4 +31,2 @@ const optionPattern = /^-{1,2}[\w]/; | ||
} | ||
/** @type {(key: string | null, value: string) => void} */ | ||
add(key, value) { | ||
@@ -55,4 +39,2 @@ if (key == null) { | ||
} | ||
/** @type {(query: number | string | string[]) => boolean} */ | ||
has(query) { | ||
@@ -65,7 +47,2 @@ if (typeof query === 'number') { | ||
} | ||
/** | ||
Get the last value for one or several option names, or a specific positional index. | ||
@type {(query: number | string | string[]) => string | undefined} | ||
*/ | ||
get(query) { | ||
@@ -78,14 +55,6 @@ if (typeof query === 'number') { | ||
} | ||
/** | ||
Get mapped values for one or several option names. | ||
Values are merged in order of appearance. | ||
@type {(query: string | string[]) => string[]} query | ||
*/ | ||
all(query) { | ||
return this.#map.filter(this.#mapFilter(query)).map((entry) => entry[1]); | ||
} | ||
keys() { | ||
/** @type {string[]} */ | ||
const keys = []; | ||
@@ -97,3 +66,2 @@ for (const [key] of this.#map) { | ||
} | ||
data() { | ||
@@ -106,4 +74,2 @@ return structuredClone({ | ||
} | ||
/** @type {(include?: string, entries?: string[][]) => HttpHeaderRule} */ | ||
function makeHeadersRule(include = '', entries = []) { | ||
@@ -115,4 +81,2 @@ const headers = Object.fromEntries(entries); | ||
} | ||
/** @type {(value: string) => string} */ | ||
function normalizeExt(value = '') { | ||
@@ -124,4 +88,2 @@ if (typeof value === 'string' && value.length && !value.startsWith('.')) { | ||
} | ||
/** @type {(args: CLIArgs, context: { onError(msg: string): void }) => Partial<ServerOptions>} */ | ||
export function parseArgs(args, { onError }) { | ||
@@ -133,4 +95,2 @@ const invalid = (optName = '', input = '') => { | ||
}; | ||
/** @type {(spec: OptionSpec) => string | undefined} */ | ||
const getStr = ({ names: argNames, negate: negativeArg }) => { | ||
@@ -141,4 +101,2 @@ if (negativeArg && args.has(negativeArg)) return; | ||
}; | ||
/** @type {(spec: OptionSpec) => string[] | undefined} */ | ||
const getList = ({ names: argNames, negate: negativeArg }) => { | ||
@@ -149,4 +107,2 @@ if (negativeArg && args.has(negativeArg)) return []; | ||
}; | ||
/** @type {(spec: OptionSpec, emptyValue?: boolean) => boolean | undefined} */ | ||
const getBool = ({ names: argNames, negate: negativeArg }, emptyValue) => { | ||
@@ -160,4 +116,2 @@ if (negativeArg && args.has(negativeArg)) return false; | ||
}; | ||
/** @type {Partial<ServerOptions>} */ | ||
const options = { | ||
@@ -172,4 +126,2 @@ root: args.get(0), | ||
}; | ||
// args that require extra parsing | ||
const port = getStr(CLI_OPTIONS.port); | ||
@@ -181,3 +133,2 @@ if (port != null) { | ||
} | ||
const headers = args | ||
@@ -195,3 +146,2 @@ .all(CLI_OPTIONS.header.names) | ||
} | ||
const ext = getList(CLI_OPTIONS.ext); | ||
@@ -201,12 +151,7 @@ if (ext != null) { | ||
} | ||
for (const name of unknownArgs(args)) { | ||
onError(`unknown option '${name}'`); | ||
} | ||
// remove undefined values | ||
return Object.fromEntries(Object.entries(options).filter((entry) => entry[1] != null)); | ||
} | ||
/** @type {(input: string) => HttpHeaderRule | undefined} */ | ||
export function parseHeaders(input) { | ||
@@ -216,4 +161,2 @@ input = input.trim(); | ||
const bracketPos = input.indexOf('{'); | ||
// parse json syntax | ||
if (bracketPos >= 0 && colonPos > bracketPos && input.endsWith('}')) { | ||
@@ -238,6 +181,3 @@ const valTypes = ['string', 'boolean', 'number']; | ||
} catch {} | ||
} | ||
// parse header:value syntax | ||
else if (colonPos > 0) { | ||
} else if (colonPos > 0) { | ||
const key = input.slice(0, colonPos).trim(); | ||
@@ -252,4 +192,2 @@ const val = input.slice(colonPos + 1).trim(); | ||
} | ||
/** @type {(input: string) => number[] | undefined} */ | ||
export function parsePort(input) { | ||
@@ -270,6 +208,3 @@ const matches = input.match(/^(?<start>\d{1,})(?<end>\+|-\d{1,})?$/); | ||
} | ||
/** @type {(values: string[]) => string[]} */ | ||
export function splitOptionValue(values) { | ||
/** @type {string[]} */ | ||
const result = []; | ||
@@ -284,18 +219,12 @@ for (let value of values.flatMap((s) => s.split(','))) { | ||
} | ||
/** @type {(input?: string, emptyValue?: boolean) => boolean | undefined} */ | ||
export function strToBool(input, emptyValue) { | ||
if (typeof input === 'string') { | ||
input = input.trim().toLowerCase(); | ||
} | ||
if (input === 'true' || input === '1') { | ||
const val = typeof input === 'string' ? input.trim().toLowerCase() : undefined; | ||
if (val === 'true' || val === '1') { | ||
return true; | ||
} else if (input === 'false' || input === '0') { | ||
} else if (val === 'false' || val === '0') { | ||
return false; | ||
} else if (input === '') { | ||
} else if (val === '') { | ||
return emptyValue; | ||
} | ||
} | ||
/** @type {(args: CLIArgs) => string[]} */ | ||
export function unknownArgs(args) { | ||
@@ -302,0 +231,0 @@ const known = Object.values(CLI_OPTIONS).flatMap((spec) => { |
116
lib/cli.js
import { createServer } from 'node:http'; | ||
import { createRequire } from 'node:module'; | ||
import { homedir, networkInterfaces } from 'node:os'; | ||
@@ -7,3 +6,2 @@ import { sep as dirSep } from 'node:path'; | ||
import { emitKeypressEvents } from 'node:readline'; | ||
import { CLIArgs, parseArgs } from './args.js'; | ||
@@ -17,14 +15,4 @@ import { CLI_OPTIONS, HOSTS_LOCAL, HOSTS_WILDCARD } from './constants.js'; | ||
import { clamp, errorList, getRuntime, isPrivateIPv4 } from './utils.js'; | ||
/** | ||
@typedef {import('./types.d.ts').OptionSpec} OptionSpec | ||
@typedef {import('./types.d.ts').ServerOptions} ServerOptions | ||
*/ | ||
/** | ||
Start servitsy with configuration from command line arguments. | ||
*/ | ||
export async function run() { | ||
const args = new CLIArgs(argv.slice(2)); | ||
if (args.has('--version')) { | ||
@@ -40,3 +28,2 @@ const pkg = readPkgJson(); | ||
} | ||
const onError = errorList(); | ||
@@ -46,3 +33,2 @@ const userOptions = parseArgs(args, { onError }); | ||
await checkDirAccess(options.root, { onError }); | ||
if (onError.list.length) { | ||
@@ -54,27 +40,13 @@ logger.error(...onError.list); | ||
} | ||
const cliServer = new CLIServer(options); | ||
cliServer.start(); | ||
} | ||
export class CLIServer { | ||
/** @type {ServerOptions} */ | ||
#options; | ||
/** @type {number | undefined} */ | ||
#port; | ||
/** @type {IterableIterator<number>} */ | ||
#portIterator; | ||
/** @type {import('node:os').NetworkInterfaceInfo | undefined} */ | ||
#localNetworkInfo; | ||
/** @type {import('node:http').Server} */ | ||
#server; | ||
/** @type {FileResolver} */ | ||
#resolver; | ||
/** @param {ServerOptions} options */ | ||
#shuttingDown = false; | ||
constructor(options) { | ||
@@ -86,3 +58,2 @@ this.#options = options; | ||
.find((c) => c?.family === 'IPv4' && isPrivateIPv4(c?.address)); | ||
const resolver = new FileResolver(options); | ||
@@ -101,7 +72,5 @@ const server = createServer(async (req, res) => { | ||
}); | ||
this.#resolver = resolver; | ||
this.#server = server; | ||
} | ||
start() { | ||
@@ -114,4 +83,2 @@ this.handleSignals(); | ||
}, | ||
// Wait until the server started listening — and hopefully all Deno | ||
// permission requests are done — before we can take over stdin inputs. | ||
() => { | ||
@@ -122,3 +89,2 @@ this.handleKeyboardInput(); | ||
} | ||
headerInfo() { | ||
@@ -129,4 +95,4 @@ const { host, root } = this.#options; | ||
const { local, network } = displayHosts({ | ||
configuredHost: host, | ||
currentHost: address.address, | ||
configured: host, | ||
actual: address.address, | ||
networkAddress: this.#localNetworkInfo?.address, | ||
@@ -150,3 +116,2 @@ }); | ||
} | ||
handleKeyboardInput() { | ||
@@ -157,8 +122,3 @@ if (!stdin.isTTY) return; | ||
stdin.on('keypress', (_str, key) => { | ||
if ( | ||
// control+c | ||
key.sequence === '\x03' || | ||
// escape | ||
key.sequence === '\x1B' | ||
) { | ||
if (key.sequence === '\x03' || key.sequence === '\x1B') { | ||
this.shutdown(); | ||
@@ -172,3 +132,2 @@ } else if (!helpShown) { | ||
} | ||
handleSignals() { | ||
@@ -179,8 +138,5 @@ process.on('SIGBREAK', this.shutdown); | ||
} | ||
#shuttingDown = false; | ||
shutdown = async () => { | ||
if (this.#shuttingDown) return; | ||
this.#shuttingDown = true; | ||
process.exitCode = 0; | ||
@@ -191,9 +147,5 @@ const promise = logger.write('info', 'Gracefully shutting down...'); | ||
await promise; | ||
exit(); | ||
}; | ||
/** @type {(error: NodeJS.ErrnoException & {hostname?: string}) => void} */ | ||
#onServerError(error) { | ||
// Try restarting with the next port | ||
if (error.code === 'EADDRINUSE') { | ||
@@ -218,4 +170,2 @@ const { value: nextPort } = this.#portIterator.next(); | ||
} | ||
// Handle other errors | ||
if (error.code === 'ENOTFOUND') { | ||
@@ -229,8 +179,6 @@ logger.error(`host not found: '${error.hostname}'`); | ||
} | ||
export function helpPage() { | ||
const spaces = (count = 0) => ' '.repeat(count); | ||
const indent = spaces(2); | ||
/** @type {Array<keyof CLI_OPTIONS>} */ | ||
const colGap = spaces(4); | ||
const optionsOrder = [ | ||
@@ -250,4 +198,2 @@ 'help', | ||
const options = optionsOrder.map((key) => CLI_OPTIONS[key]); | ||
/** @type {(heading?: string, lines?: string[]) => string} */ | ||
const section = (heading = '', lines = []) => { | ||
@@ -259,8 +205,8 @@ const result = []; | ||
}; | ||
/** @type {(options: OptionSpec[], config: {gap: string, firstWidth: number}) => string[]} */ | ||
const optionCols = (options, { gap, firstWidth }) => | ||
options.flatMap(({ help, names, default: argDefault = '' }) => { | ||
const optionCols = () => { | ||
const hMaxLength = Math.max(...options.map((opt) => opt.names.join(', ').length)); | ||
const firstWidth = clamp(hMaxLength, 14, 20); | ||
return options.flatMap(({ help, names, default: argDefault = '' }) => { | ||
const header = names.join(', ').padEnd(firstWidth); | ||
const first = `${header}${gap}${help}`; | ||
const first = `${header}${colGap}${help}`; | ||
if (!argDefault) return [first]; | ||
@@ -272,6 +218,6 @@ const secondRaw = `(default: '${Array.isArray(argDefault) ? argDefault.join(', ') : argDefault}')`; | ||
} else { | ||
return [first, spaces(header.length + gap.length) + second]; | ||
return [first, spaces(header.length + colGap.length) + second]; | ||
} | ||
}); | ||
}; | ||
return [ | ||
@@ -285,42 +231,18 @@ section( | ||
]), | ||
section( | ||
'OPTIONS', | ||
optionCols(options, { | ||
gap: spaces(4), | ||
firstWidth: clamp(Math.max(...options.map((opt) => opt.names.join(', ').length)), 14, 20), | ||
}), | ||
), | ||
section('OPTIONS', optionCols()), | ||
].join('\n\n'); | ||
} | ||
/** | ||
@param {{ configuredHost: string; currentHost: string; networkAddress?: string }} address | ||
@returns {{ local: string; network?: string }} | ||
*/ | ||
function displayHosts({ configuredHost, currentHost, networkAddress }) { | ||
function displayHosts({ configured, actual, networkAddress }) { | ||
const isLocalhost = (value = '') => HOSTS_LOCAL.includes(value); | ||
const isWildcard = (value = '') => HOSTS_WILDCARD.v4 === value || HOSTS_WILDCARD.v6 === value; | ||
if (!isWildcard(configuredHost) && !isLocalhost(configuredHost)) { | ||
return { local: configuredHost }; | ||
if (!isWildcard(configured) && !isLocalhost(configured)) { | ||
return { local: configured }; | ||
} | ||
return { | ||
local: isWildcard(currentHost) || isLocalhost(currentHost) ? 'localhost' : currentHost, | ||
network: | ||
isWildcard(configuredHost) && getRuntime() !== 'webcontainer' ? networkAddress : undefined, | ||
local: isWildcard(actual) || isLocalhost(actual) ? 'localhost' : actual, | ||
network: isWildcard(configured) && getRuntime() !== 'webcontainer' ? networkAddress : undefined, | ||
}; | ||
} | ||
/** | ||
Replace the home dir with '~' in path | ||
@type {(root: string) => string} | ||
*/ | ||
function displayRoot(root) { | ||
if ( | ||
// skip: not a common windows convention | ||
platform !== 'win32' && | ||
// skip: requires --allow-sys=homedir in Deno | ||
getRuntime() !== 'deno' | ||
) { | ||
if (platform !== 'win32' && getRuntime() !== 'deno') { | ||
const prefix = homedir() + dirSep; | ||
@@ -327,0 +249,0 @@ if (root.startsWith(prefix)) { |
@@ -1,4 +0,2 @@ | ||
/** @type {string[]} */ | ||
export const HOSTS_LOCAL = ['localhost', '127.0.0.1', '::1']; | ||
export const HOSTS_WILDCARD = { | ||
@@ -8,3 +6,2 @@ v4: '0.0.0.0', | ||
}; | ||
export const PORTS_CONFIG = { | ||
@@ -15,9 +12,4 @@ initial: 8080, | ||
}; | ||
/** @type {string[]} */ | ||
export const SUPPORTED_METHODS = ['GET', 'HEAD', 'OPTIONS', 'POST']; | ||
export const MAX_COMPRESS_SIZE = 50_000_000; | ||
/** @type {Omit<import('./types.d.ts').ServerOptions, 'root'>} */ | ||
export const DEFAULT_OPTIONS = { | ||
@@ -34,7 +26,2 @@ host: HOSTS_WILDCARD.v6, | ||
}; | ||
/** | ||
@typedef {'cors' | 'dirFile' | 'dirList' | 'exclude' | 'ext' | 'gzip' | 'header' | 'help' | 'host' | 'port' | 'version'} OptionName | ||
@type {Record<OptionName, import('./types.d.ts').OptionSpec>} | ||
*/ | ||
export const CLI_OPTIONS = { | ||
@@ -41,0 +28,0 @@ cors: { |
import { basename, extname } from 'node:path'; | ||
/** | ||
@typedef {import('node:fs/promises').FileHandle} FileHandle | ||
@typedef {{ | ||
default: string; | ||
file: string[]; | ||
extension: string[]; | ||
extensionMap: Record<string, string>; | ||
suffix: string[]; | ||
}} TypeMap | ||
*/ | ||
const strarr = (s = '') => s.trim().split(/\s+/); | ||
const DEFAULT_CHARSET = 'UTF-8'; | ||
/** @type {TypeMap} */ | ||
export const TEXT_TYPES = { | ||
@@ -46,3 +30,2 @@ default: 'text/plain', | ||
}, | ||
// Loosely based on npm:textextensions | ||
extension: strarr(` | ||
@@ -77,4 +60,2 @@ ada adb ads as ascx asm asmx asp aspx astro atom | ||
}; | ||
/** @type {TypeMap} */ | ||
const BIN_TYPES = { | ||
@@ -144,15 +125,9 @@ default: 'application/octet-stream', | ||
}; | ||
export class TypeResult { | ||
/** @type {'text' | 'bin' | 'unknown'} */ | ||
group = 'unknown'; | ||
/** @type {string} */ | ||
type = BIN_TYPES.default; | ||
/** @param {string | null} [charset] */ | ||
constructor(charset = DEFAULT_CHARSET) { | ||
this.charset = charset; | ||
charset = ''; | ||
constructor(charset = 'UTF-8') { | ||
if (typeof charset === 'string') this.charset = charset; | ||
} | ||
bin(type = BIN_TYPES.default) { | ||
@@ -163,3 +138,2 @@ this.group = 'bin'; | ||
} | ||
text(type = TEXT_TYPES.default) { | ||
@@ -170,3 +144,2 @@ this.group = 'text'; | ||
} | ||
unknown() { | ||
@@ -177,3 +150,2 @@ this.group = 'unknown'; | ||
} | ||
toString() { | ||
@@ -187,10 +159,6 @@ if (this.group === 'text') { | ||
} | ||
/** @type {(filePath: string, charset?: string | null) => TypeResult} */ | ||
export function typeForFilePath(filePath, charset) { | ||
const result = new TypeResult(charset); | ||
const name = filePath ? basename(filePath).toLowerCase() : ''; | ||
const ext = name ? extname(name).replace('.', '') : ''; | ||
if (ext) { | ||
@@ -211,11 +179,4 @@ if (Object.hasOwn(TEXT_TYPES.extensionMap, ext)) { | ||
} | ||
return result.unknown(); | ||
} | ||
/** | ||
@param {FileHandle} handle | ||
@param {string | null} [charset] | ||
@returns {Promise<TypeResult>} | ||
*/ | ||
export async function typeForFile(handle, charset) { | ||
@@ -237,7 +198,2 @@ const result = new TypeResult(charset); | ||
} | ||
/** | ||
@param {{ path?: string; handle?: FileHandle }} file | ||
@returns {Promise<TypeResult>} | ||
*/ | ||
export async function getContentType({ path, handle }) { | ||
@@ -256,17 +212,8 @@ if (path) { | ||
} | ||
/** | ||
https://mimesniff.spec.whatwg.org/#sniffing-a-mislabeled-binary-resource | ||
@type {(bytes: Uint8Array) => boolean} | ||
*/ | ||
export function isBinHeader(bytes) { | ||
const limit = Math.min(bytes.length, 2000); | ||
const [b0, b1, b2] = bytes; | ||
if ( | ||
// UTF-16BE BOM | ||
(b0 === 0xfe && b1 === 0xff) || | ||
// UTF-16LE BOM | ||
(b0 === 0xff && b1 === 0xfe) || | ||
// UTF-8 BOM | ||
(b0 === 0xef && b1 === 0xbb && b2 === 0xbf) | ||
@@ -276,3 +223,2 @@ ) { | ||
} | ||
for (let i = 0; i < limit; i++) { | ||
@@ -283,10 +229,4 @@ if (isBinDataByte(bytes[i])) { | ||
} | ||
return false; | ||
} | ||
/** | ||
https://mimesniff.spec.whatwg.org/#binary-data-byte | ||
@type {(int: number) => boolean} | ||
*/ | ||
export function isBinDataByte(int) { | ||
@@ -293,0 +233,0 @@ if (int >= 0 && int <= 0x1f) { |
import { access, constants, lstat, readdir, realpath, stat } from 'node:fs/promises'; | ||
import { createRequire } from 'node:module'; | ||
import { isAbsolute, join, sep as dirSep } from 'node:path'; | ||
import { trimSlash } from './utils.js'; | ||
/** | ||
@typedef {import('./types.d.ts').FSKind} FSKind | ||
@typedef {import('./types.d.ts').FSLocation} FSLocation | ||
*/ | ||
/** @type {(dirPath: string, context: { onError(msg: string): void }) => Promise<boolean>} */ | ||
export async function checkDirAccess(dirPath, { onError }) { | ||
export async function checkDirAccess(dirPath, context) { | ||
let msg = ''; | ||
@@ -18,3 +10,2 @@ try { | ||
if (stats.isDirectory()) { | ||
// needs r-x permissions to access contents of the directory | ||
await access(dirPath, constants.R_OK | constants.X_OK); | ||
@@ -25,3 +16,3 @@ return true; | ||
} | ||
} catch (/** @type {any} */ err) { | ||
} catch (err) { | ||
if (err.code === 'ENOENT') { | ||
@@ -35,7 +26,5 @@ msg = `not a directory: ${dirPath}`; | ||
} | ||
if (msg) onError(msg); | ||
if (msg) context.onError(msg); | ||
return false; | ||
} | ||
/** @type {(dirPath: string) => Promise<FSLocation[]>} */ | ||
export async function getIndex(dirPath) { | ||
@@ -52,4 +41,2 @@ try { | ||
} | ||
/** @type {(filePath: string) => Promise<FSKind>} */ | ||
export async function getKind(filePath) { | ||
@@ -63,4 +50,2 @@ try { | ||
} | ||
/** @type {(root: string, filePath: string) => string | null} */ | ||
export function getLocalPath(root, filePath) { | ||
@@ -72,4 +57,2 @@ if (isSubpath(root, filePath)) { | ||
} | ||
/** @type {(filePath: string) => Promise<string | null>} */ | ||
export async function getRealpath(filePath) { | ||
@@ -83,4 +66,2 @@ try { | ||
} | ||
/** @type {(filePath: string, kind?: FSKind) => Promise<boolean>} */ | ||
export async function isReadable(filePath, kind) { | ||
@@ -99,4 +80,2 @@ if (kind === undefined) { | ||
} | ||
/** @type {(parent: string, filePath: string) => boolean} */ | ||
export function isSubpath(parent, filePath) { | ||
@@ -107,9 +86,5 @@ if (filePath.includes('..') || !isAbsolute(filePath)) return false; | ||
} | ||
/** @type {() => Record<string, any>} */ | ||
export function readPkgJson() { | ||
return createRequire(import.meta.url)('../package.json'); | ||
} | ||
/** @type {(stats: {isSymbolicLink?(): boolean; isDirectory?(): boolean; isFile?(): boolean}) => FSKind} */ | ||
export function statsKind(stats) { | ||
@@ -116,0 +91,0 @@ if (stats.isSymbolicLink?.()) return 'link'; |
@@ -5,3 +5,2 @@ import { Buffer } from 'node:buffer'; | ||
import { createGzip, gzipSync } from 'node:zlib'; | ||
import { MAX_COMPRESS_SIZE, SUPPORTED_METHODS } from './constants.js'; | ||
@@ -12,24 +11,3 @@ import { getContentType, typeForFilePath } from './content-type.js'; | ||
import { PathMatcher } from './path-matcher.js'; | ||
import { headerCase } from './utils.js'; | ||
/** | ||
@typedef {import('./types.d.ts').FSLocation} FSLocation | ||
@typedef {import('./types.d.ts').ResMetaData} ResMetaData | ||
@typedef {import('./types.d.ts').ServerOptions} ServerOptions | ||
*/ | ||
/** | ||
@typedef {{ | ||
req: import('node:http').IncomingMessage; | ||
res: import('node:http').ServerResponse<import('node:http').IncomingMessage>; | ||
resolver: import('./resolver.js').FileResolver; | ||
options: ServerOptions & {_noStream?: boolean} | ||
}} ReqHandlerConfig | ||
@typedef {{ | ||
body?: string | Buffer | import('node:fs').ReadStream; | ||
contentType?: string; | ||
isText?: boolean; | ||
statSize?: number; | ||
}} SendPayload | ||
*/ | ||
import { headerCase, trimSlash } from './utils.js'; | ||
export class RequestHandler { | ||
@@ -40,16 +18,6 @@ #req; | ||
#options; | ||
/** @type {ResMetaData['timing']} */ | ||
timing = { start: Date.now() }; | ||
/** @type {string} */ | ||
urlPath = ''; | ||
/** @type {FSLocation | null} */ | ||
urlPath = null; | ||
file = null; | ||
/** | ||
Error that may be logged to the terminal | ||
@type {Error | string | undefined} | ||
*/ | ||
error; | ||
/** @param {ReqHandlerConfig} config */ | ||
constructor({ req, res, resolver, options }) { | ||
@@ -60,4 +28,6 @@ this.#req = req; | ||
this.#options = options; | ||
if (typeof req.url === 'string') { | ||
this.urlPath = req.url.split(/[\?\#]/)[0]; | ||
try { | ||
this.urlPath = extractUrlPath(req.url ?? ''); | ||
} catch (err) { | ||
this.error = err; | ||
} | ||
@@ -68,3 +38,2 @@ res.on('close', () => { | ||
} | ||
get method() { | ||
@@ -89,5 +58,3 @@ return this.#req.method ?? ''; | ||
} | ||
async process() { | ||
// bail for unsupported http methods | ||
if (!SUPPORTED_METHODS.includes(this.method)) { | ||
@@ -98,4 +65,2 @@ this.status = 405; | ||
} | ||
// no need to look up files for the '*' OPTIONS request | ||
if (this.method === 'OPTIONS' && this.urlPath === '*') { | ||
@@ -106,34 +71,25 @@ this.status = 204; | ||
} | ||
const { status, urlPath, file = null } = await this.#resolver.find(this.urlPath); | ||
if (this.urlPath == null) { | ||
this.status = 400; | ||
return this.#sendErrorPage(); | ||
} | ||
const localPath = trimSlash(decodeURIComponent(this.urlPath)); | ||
const { status, file } = await this.#resolver.find(localPath); | ||
this.status = status; | ||
this.urlPath = urlPath; | ||
this.file = file; | ||
// found a file to serve | ||
if (status === 200 && file?.kind === 'file') { | ||
return this.#sendFile(file.filePath); | ||
} | ||
// found a directory that we can show a listing for | ||
if (status === 200 && file?.kind === 'dir' && this.#options.dirList) { | ||
return this.#sendListPage(file.filePath); | ||
} | ||
return this.#sendErrorPage(); | ||
} | ||
/** @type {(filePath: string) => Promise<void>} */ | ||
async #sendFile(filePath) { | ||
/** @type {import('node:fs/promises').FileHandle | undefined} */ | ||
let handle; | ||
/** @type {SendPayload} */ | ||
let data = {}; | ||
try { | ||
// already checked in resolver, but better safe than sorry | ||
if (!isSubpath(this.#options.root, filePath)) { | ||
throw new Error(`File '${filePath}' is not contained in root: '${this.#options.root}'`); | ||
} | ||
// check that we can open the file (especially on windows where it might be busy) | ||
handle = await open(filePath); | ||
@@ -146,3 +102,3 @@ const type = await getContentType({ path: filePath, handle }); | ||
}; | ||
} catch (/** @type {any} */ err) { | ||
} catch (err) { | ||
this.status = err?.code === 'EBUSY' ? 403 : 500; | ||
@@ -153,7 +109,5 @@ if (err && (err.message || typeof err === 'object')) this.error = err; | ||
} | ||
if (this.status >= 400) { | ||
return this.#sendErrorPage(); | ||
} | ||
this.#setHeaders(filePath, { | ||
@@ -164,15 +118,9 @@ contentType: data.contentType, | ||
}); | ||
if (this.method === 'OPTIONS') { | ||
this.status = 204; | ||
} | ||
// read file as stream | ||
else if (this.method !== 'HEAD' && !this.#options._noStream) { | ||
} else if (this.method !== 'HEAD' && !this.#options._noStream) { | ||
data.body = createReadStream(filePath, { autoClose: true, start: 0 }); | ||
} | ||
return this.#send(data); | ||
} | ||
/** @type {(filePath: string) => Promise<void>} */ | ||
async #sendListPage(filePath) { | ||
@@ -183,3 +131,2 @@ this.#setHeaders('index.html', { | ||
}); | ||
if (this.method === 'OPTIONS') { | ||
@@ -189,9 +136,12 @@ this.status = 204; | ||
} | ||
const items = await this.#resolver.index(filePath); | ||
const body = await dirListPage({ urlPath: this.urlPath, filePath, items }, this.#options); | ||
const body = dirListPage({ | ||
root: this.#options.root, | ||
ext: this.#options.ext, | ||
urlPath: this.urlPath ?? '', | ||
filePath, | ||
items, | ||
}); | ||
return this.#send({ body, isText: true }); | ||
} | ||
/** @type {() => Promise<void>} */ | ||
async #sendErrorPage() { | ||
@@ -202,18 +152,14 @@ this.#setHeaders('error.html', { | ||
}); | ||
if (this.method === 'OPTIONS') { | ||
return this.#send(); | ||
} | ||
return this.#send({ | ||
body: await errorPage({ status: this.status, urlPath: this.urlPath }), | ||
isText: true, | ||
const body = errorPage({ | ||
status: this.status, | ||
url: this.#req.url ?? '', | ||
urlPath: this.urlPath, | ||
}); | ||
return this.#send({ body, isText: true }); | ||
} | ||
/** @type {(payload?: SendPayload) => void} */ | ||
#send({ body, isText = false, statSize } = {}) { | ||
this.timing.send = Date.now(); | ||
// stop early if possible | ||
if (this.#req.destroyed) { | ||
@@ -227,3 +173,2 @@ this.#res.end(); | ||
} | ||
const isHead = this.method === 'HEAD'; | ||
@@ -233,4 +178,2 @@ const compress = | ||
canCompress({ accept: this.#req.headers['accept-encoding'], isText, statSize }); | ||
// Send file contents if already available | ||
if (typeof body === 'string' || Buffer.isBuffer(body)) { | ||
@@ -248,9 +191,5 @@ const buf = compress ? gzipSync(body) : Buffer.from(body); | ||
} | ||
// No content-length when compressing: we can't use the stat size, | ||
// and compressing all at once would defeat streaming and/or run out of memory | ||
if (typeof statSize === 'number' && !compress) { | ||
this.#header('content-length', String(statSize)); | ||
} | ||
if (isHead || body == null) { | ||
@@ -260,4 +199,2 @@ this.#res.end(); | ||
} | ||
// Send file stream | ||
if (compress) { | ||
@@ -270,6 +207,2 @@ this.#header('content-encoding', 'gzip'); | ||
} | ||
/** | ||
@type {(name: string, value: null | number | string | string[], normalizeCase?: boolean) => void} | ||
*/ | ||
#header(name, value, normalizeCase = true) { | ||
@@ -285,26 +218,17 @@ if (this.#res.headersSent) return; | ||
} | ||
/** | ||
Set all response headers, except for content-length | ||
@type {(filePath: string, options: Partial<{ contentType: string, cors: boolean; headers: ServerOptions['headers'] }>) => void} | ||
*/ | ||
#setHeaders(filePath, { contentType, cors, headers }) { | ||
#setHeaders(filePath, options) { | ||
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) { | ||
contentType ??= typeForFilePath(filePath).toString(); | ||
this.#header('content-type', contentType); | ||
const value = contentType ?? typeForFilePath(filePath).toString(); | ||
this.#header('content-type', value); | ||
} | ||
if (cors ?? this.#options.cors) { | ||
this.#setCorsHeaders(); | ||
} | ||
const localPath = getLocalPath(this.#options.root, filePath); | ||
@@ -318,3 +242,2 @@ if (localPath != null && headerRules.length) { | ||
} | ||
#setCorsHeaders() { | ||
@@ -333,4 +256,2 @@ const origin = this.#req.headers['origin']; | ||
} | ||
/** @type {() => ResMetaData} */ | ||
data() { | ||
@@ -340,2 +261,3 @@ return { | ||
method: this.method, | ||
url: this.#req.url ?? '', | ||
urlPath: this.urlPath, | ||
@@ -348,6 +270,2 @@ localPath: this.localPath, | ||
} | ||
/** | ||
@type {(data: { accept?: string | string[]; isText?: boolean; statSize?: number }) => boolean} | ||
*/ | ||
function canCompress({ accept = '', statSize = 0, isText = false }) { | ||
@@ -363,8 +281,11 @@ accept = Array.isArray(accept) ? accept.join(',') : accept; | ||
} | ||
/** | ||
@type {(localPath: string, rules: ServerOptions['headers'], blockList?: string[]) => Array<{name: string; value: string}>} | ||
*/ | ||
export function extractUrlPath(url) { | ||
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, rules, blockList = []) { | ||
/** @type {ReturnType<fileHeaders>} */ | ||
const result = []; | ||
@@ -383,13 +304,20 @@ for (const rule of rules) { | ||
} | ||
/** @type {(req: Pick<import('node:http').IncomingMessage, 'method' | 'headers'>) => boolean} */ | ||
function isPreflight({ method, headers }) { | ||
function isPreflight(req) { | ||
return ( | ||
method === 'OPTIONS' && | ||
typeof headers['origin'] === 'string' && | ||
typeof headers['access-control-request-method'] === 'string' | ||
req.method === 'OPTIONS' && | ||
typeof req.headers['origin'] === 'string' && | ||
typeof req.headers['access-control-request-method'] === 'string' | ||
); | ||
} | ||
/** @type {(input?: string) => string[]} */ | ||
export function isValidUrlPath(urlPath) { | ||
if (urlPath === '/') return true; | ||
if (!urlPath.startsWith('/') || urlPath.includes('//')) return false; | ||
for (const s of trimSlash(urlPath).split('/')) { | ||
const d = decodeURIComponent(s); | ||
if (d === '.' || d === '..') return false; | ||
if (s.includes('?') || s.includes('#')) return false; | ||
if (d.includes('/') || d.includes('\\')) return false; | ||
} | ||
return true; | ||
} | ||
function parseHeaderNames(input = '') { | ||
@@ -396,0 +324,0 @@ const isHeader = (h = '') => /^[A-Za-z\d-_]+$/.test(h); |
@@ -5,32 +5,11 @@ import { release } from 'node:os'; | ||
import { inspect } from 'node:util'; | ||
import { clamp, fwdSlash, getEnv, trimSlash, withResolvers } from './utils.js'; | ||
/** | ||
@typedef {import('./types.d.ts').ResMetaData} ResMetaData | ||
@typedef {{ | ||
group: 'header' | 'info' | 'request' | 'error'; | ||
text: string; | ||
padding: {top: number; bottom: number}; | ||
}} LogItem | ||
*/ | ||
import { clamp, fwdSlash, getEnv, getRuntime, trimSlash, withResolvers } from './utils.js'; | ||
export class ColorUtils { | ||
/** @param {boolean} [colorEnabled] */ | ||
enabled; | ||
constructor(colorEnabled) { | ||
this.enabled = typeof colorEnabled === 'boolean' ? colorEnabled : true; | ||
} | ||
/** @type {(text: string, format?: string) => string} */ | ||
style = (text, format = '') => { | ||
if (!this.enabled) return text; | ||
return styleText(format.trim().split(/\s+/g), text); | ||
}; | ||
/** @type {(text: string, format?: string, chars?: [string, string]) => string} */ | ||
brackets = (text, format = 'dim,,dim', chars = ['[', ']']) => { | ||
return this.sequence([chars[0], text, chars[1]], format); | ||
}; | ||
/** @type {(parts: string[], format?: string) => string} */ | ||
sequence = (parts, format = '') => { | ||
@@ -45,35 +24,10 @@ if (!format || !this.enabled) { | ||
}; | ||
/** @type {(input: string) => string} */ | ||
strip = stripStyle; | ||
style = (text, format = '') => { | ||
if (!this.enabled) return text; | ||
return styleText(format.trim().split(/\s+/g), text); | ||
}; | ||
} | ||
class Logger { | ||
/** @type {LogItem | null} */ | ||
#lastout = null; | ||
/** @type {LogItem | null} */ | ||
#lasterr = null; | ||
/** | ||
@type {(prev: LogItem | null, next: LogItem) => string} | ||
*/ | ||
#withPadding(prev, { group, text, padding }) { | ||
const maxPad = 4; | ||
let start = ''; | ||
let end = ''; | ||
if (padding.top) { | ||
const count = padding.top - (prev?.padding.bottom ?? 0); | ||
start = '\n'.repeat(clamp(count, 0, maxPad)); | ||
} else if (prev && !prev.padding.bottom && prev.group !== group) { | ||
start = '\n'; | ||
} | ||
if (padding.bottom) { | ||
end = '\n'.repeat(clamp(padding.bottom, 0, maxPad)); | ||
} | ||
return `${start}${text}\n${end}`; | ||
} | ||
/** | ||
@type {(group: LogItem['group'], data: string | string[], padding?: LogItem['padding']) => Promise<void>} | ||
*/ | ||
#lastout; | ||
#lasterr; | ||
async write(group, data = '', padding = { top: 0, bottom: 0 }) { | ||
@@ -88,9 +42,7 @@ const item = { | ||
} | ||
const { promise, resolve, reject } = withResolvers(); | ||
const writeCallback = (/** @type {Error|undefined} */ err) => { | ||
const writeCallback = (err) => { | ||
if (err) reject(err); | ||
else resolve(); | ||
}; | ||
if (group === 'error') { | ||
@@ -103,11 +55,6 @@ stderr.write(this.#withPadding(this.#lasterr, item), writeCallback); | ||
} | ||
return promise; | ||
} | ||
/** | ||
@type {(...errors: Array<string | Error>) => void} | ||
*/ | ||
error(...errors) { | ||
this.write( | ||
return this.write( | ||
'error', | ||
@@ -120,15 +67,26 @@ errors.map((error) => { | ||
} | ||
#withPadding(prev, item) { | ||
const maxPad = 4; | ||
let start = ''; | ||
let end = ''; | ||
if (item.padding.top) { | ||
const count = item.padding.top - (prev?.padding.bottom ?? 0); | ||
start = '\n'.repeat(clamp(count, 0, maxPad)); | ||
} else if (prev && !prev.padding.bottom && prev.group !== item.group) { | ||
start = '\n'; | ||
} | ||
if (item.padding.bottom) { | ||
end = '\n'.repeat(clamp(item.padding.bottom, 0, maxPad)); | ||
} | ||
return `${start}${item.text}\n${end}`; | ||
} | ||
} | ||
/** @type {(data: import('./types.d.ts').ResMetaData) => string} */ | ||
export function requestLogLine({ status, method, urlPath, localPath, timing, error }) { | ||
export function requestLogLine({ status, method, url, urlPath, localPath, timing, error }) { | ||
const { start, close } = timing; | ||
const { style: _, brackets } = color; | ||
const isSuccess = status >= 200 && status < 300; | ||
const timestamp = start ? new Date(start).toTimeString().split(' ')[0]?.padStart(8) : undefined; | ||
const duration = start && close ? Math.ceil(close - start) : undefined; | ||
let displayPath = _(urlPath, 'cyan'); | ||
if (isSuccess && localPath != null) { | ||
let displayPath = _(urlPath ?? url, 'cyan'); | ||
if (isSuccess && urlPath != null && localPath != null) { | ||
const basePath = urlPath.length > 1 ? trimSlash(urlPath, { end: true }) : urlPath; | ||
@@ -141,3 +99,2 @@ const suffix = pathSuffix(basePath, `/${fwdSlash(localPath)}`); | ||
} | ||
const line = [ | ||
@@ -153,3 +110,2 @@ timestamp && _(timestamp, 'dim'), | ||
.join(' '); | ||
if (!isSuccess && error) { | ||
@@ -160,4 +116,2 @@ return `${line}\n${_(error.toString(), 'red')}`; | ||
} | ||
/** @type {(basePath: string, fullPath: string) => string | undefined} */ | ||
function pathSuffix(basePath, fullPath) { | ||
@@ -170,15 +124,2 @@ if (basePath === fullPath) { | ||
} | ||
/** @type {(input: string) => string} */ | ||
export function stripStyle(input) { | ||
if (typeof input === 'string' && input.includes('\x1b[')) { | ||
return input.replace(/\x1b\[\d+m/g, ''); | ||
} | ||
return input; | ||
} | ||
/** | ||
Basic implementation of 'node:util' styleText to support Node 18 + Deno. | ||
@type {(format: string | string[], text: string) => string} | ||
*/ | ||
export function styleText(format, text) { | ||
@@ -195,9 +136,6 @@ let before = ''; | ||
} | ||
/** @type {() => boolean} */ | ||
function supportsColor() { | ||
if (typeof globalThis.Deno?.noColor === 'boolean') { | ||
return !globalThis.Deno.noColor; | ||
if (getRuntime() === 'deno') { | ||
return globalThis.Deno?.noColor !== true; | ||
} | ||
if (getEnv('NO_COLOR')) { | ||
@@ -207,5 +145,2 @@ const forceColor = getEnv('FORCE_COLOR'); | ||
} | ||
// Logic borrowed from supports-color. | ||
// Windows 10 build 10586 is the first release that supports 256 colors. | ||
if (platform === 'win32') { | ||
@@ -215,4 +150,2 @@ const [major, _, build] = release().split('.'); | ||
} | ||
// Should work in *nix terminals. | ||
const term = getEnv('TERM'); | ||
@@ -227,4 +160,3 @@ const colorterm = getEnv('COLORTERM'); | ||
} | ||
export const color = new ColorUtils(supportsColor()); | ||
export const logger = new Logger(); |
import { isAbsolute, resolve } from 'node:path'; | ||
import { DEFAULT_OPTIONS, PORTS_CONFIG } from './constants.js'; | ||
/** | ||
@typedef {import('./types.d.ts').HttpHeaderRule} HttpHeaderRule | ||
@typedef {import('./types.d.ts').ServerOptions} ServerOptions | ||
*/ | ||
export class OptionsValidator { | ||
/** @param {(msg: string) => void} [onError] */ | ||
onError; | ||
constructor(onError) { | ||
this.onError = onError; | ||
} | ||
/** | ||
@type {<T = string>(input: T[] | undefined, filterFn: (item: T) => boolean) => T[] | undefined} | ||
*/ | ||
#array(input, filterFn) { | ||
@@ -25,6 +14,2 @@ if (!Array.isArray(input)) return; | ||
} | ||
/** | ||
@type {(optName: string, input?: boolean) => boolean | undefined} | ||
*/ | ||
#bool(optName, input) { | ||
@@ -35,13 +20,8 @@ if (typeof input === 'undefined') return; | ||
} | ||
#error(msg = '') { | ||
#error(msg) { | ||
this.onError?.(msg); | ||
} | ||
/** @type {(input?: boolean) => boolean | undefined} */ | ||
cors(input) { | ||
return this.#bool('cors', input); | ||
} | ||
/** @type {(input?: string[]) => string[] | undefined} */ | ||
dirFile(input) { | ||
@@ -54,9 +34,5 @@ return this.#array(input, (item) => { | ||
} | ||
/** @type {(input?: boolean) => boolean | undefined} */ | ||
dirList(input) { | ||
return this.#bool('dirList', input); | ||
} | ||
/** @type {(input?: string[]) => string[] | undefined} */ | ||
exclude(input) { | ||
@@ -69,4 +45,2 @@ return this.#array(input, (item) => { | ||
} | ||
/** @type {(input?: string[]) => string[] | undefined} */ | ||
ext(input) { | ||
@@ -79,9 +53,5 @@ return this.#array(input, (item) => { | ||
} | ||
/** @type {(input?: boolean) => boolean | undefined} */ | ||
gzip(input) { | ||
return this.#bool('gzip', input); | ||
} | ||
/** @type {(input?: HttpHeaderRule[]) => HttpHeaderRule[] | undefined} */ | ||
headers(input) { | ||
@@ -94,4 +64,2 @@ return this.#array(input, (rule) => { | ||
} | ||
/** @type {(input?: string) => string | undefined} */ | ||
host(input) { | ||
@@ -102,4 +70,2 @@ if (typeof input !== 'string') return; | ||
} | ||
/** @type {(input?: number[]) => number[] | undefined} */ | ||
ports(input) { | ||
@@ -112,4 +78,2 @@ if (!Array.isArray(input) || input.length === 0) return; | ||
} | ||
/** @type {(input?: string) => string} */ | ||
root(input) { | ||
@@ -120,9 +84,5 @@ const value = typeof input === 'string' ? input : ''; | ||
} | ||
/** @type {(input: unknown) => input is string[]} */ | ||
export function isStringArray(input) { | ||
return Array.isArray(input) && input.every((item) => typeof item === 'string'); | ||
} | ||
/** @type {(input: string) => boolean} */ | ||
export function isValidExt(input) { | ||
@@ -132,12 +92,8 @@ if (typeof input !== 'string' || !input) return false; | ||
} | ||
/** @type {(name: string) => boolean} */ | ||
export function isValidHeader(name) { | ||
return typeof name === 'string' && /^[a-z\d\-\_]+$/i.test(name); | ||
} | ||
/** @type {(value: any) => value is HttpHeaderRule} */ | ||
export function isValidHeaderRule(value) { | ||
const include = value?.include; | ||
const headers = value?.headers; | ||
if (!value || typeof value !== 'object') return false; | ||
const { include, headers } = value; | ||
if (typeof include !== 'undefined' && !isStringArray(include)) { | ||
@@ -158,8 +114,2 @@ return false; | ||
} | ||
/** | ||
Checking that all characters are valid for a domain or ip, | ||
as a usability nicety to catch obvious errors | ||
@type {(input: string) => boolean} | ||
*/ | ||
export function isValidHost(input) { | ||
@@ -171,22 +121,10 @@ if (typeof input !== 'string' || !input.length) return false; | ||
} | ||
/** @type {(value: string) => boolean} */ | ||
export function isValidPattern(value) { | ||
return typeof value === 'string' && value.length > 0 && !/[\\\/\:]/.test(value); | ||
} | ||
/** @type {(num: number) => boolean} */ | ||
export function isValidPort(num) { | ||
return Number.isSafeInteger(num) && num >= 1 && num <= 65_535; | ||
} | ||
/** | ||
@param {{ root: string } & Partial<ServerOptions>} options | ||
@param {{ onError(msg: string): void }} [context] | ||
@returns {ServerOptions} | ||
*/ | ||
export function serverOptions(options, context) { | ||
const validator = new OptionsValidator(context?.onError); | ||
/** @type {Partial<ServerOptions>} */ | ||
const checked = { | ||
@@ -203,13 +141,12 @@ ports: validator.ports(options.ports), | ||
}; | ||
const final = { | ||
const final = structuredClone({ | ||
root: validator.root(options.root), | ||
...structuredClone(DEFAULT_OPTIONS), | ||
}; | ||
...DEFAULT_OPTIONS, | ||
}); | ||
for (const [key, value] of Object.entries(checked)) { | ||
// @ts-ignore | ||
if (typeof value !== 'undefined') final[key] = value; | ||
if (typeof value !== 'undefined') { | ||
final[key] = value; | ||
} | ||
} | ||
return final; | ||
} |
@@ -1,18 +0,7 @@ | ||
export const FAVICON_ERROR = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"> | ||
<style> | ||
svg { fill: #333 } | ||
@media (prefers-color-scheme: dark) { | ||
svg { fill: #ccc } | ||
} | ||
</style> | ||
export const FAVICON_ERROR = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" | ||
style="color-scheme: light dark; fill: light-dark(#333, #ccc)"> | ||
<path fill-rule="evenodd" d="M8 14A6 6 0 1 0 8 2a6 6 0 0 0 0 12Zm0 1.5a7.5 7.5 0 1 0 0-15 7.5 7.5 0 0 0 0 15Z M9 11a1 1 0 1 1-2 0 1 1 0 0 1 2 0ZM7 5a1 1 0 0 1 2 0v3a1 1 0 0 1-2 0V5Z"/> | ||
</svg>`; | ||
export const FAVICON_LIST = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"> | ||
<style> | ||
svg { fill: #333 } | ||
@media (prefers-color-scheme: dark) { | ||
svg { fill: #ccc } | ||
} | ||
</style> | ||
export const FAVICON_LIST = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" | ||
style="color-scheme: light dark; fill: light-dark(#333, #ccc)"> | ||
<rect x="1" y="2.75" width="2" height="2" rx="1"/> | ||
@@ -25,3 +14,2 @@ <rect x="1" y="7.25" width="2" height="2" rx="1"/> | ||
</svg>`; | ||
export const ICONS = `<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1" style="position:absolute;pointer-events:none"> | ||
@@ -41,3 +29,2 @@ <symbol id="icon-dir" viewBox="0 0 20 20"> | ||
</svg>`; | ||
export const STYLES = `@property --max-col-count { | ||
@@ -44,0 +31,0 @@ syntax: '<integer>'; |
import { basename, dirname } from 'node:path'; | ||
import { FAVICON_LIST, FAVICON_ERROR, ICONS, STYLES } from './page-assets.js'; | ||
import { clamp, escapeHtml, trimSlash } from './utils.js'; | ||
/** | ||
@typedef {import('./types.d.ts').FSLocation} FSLocation | ||
@typedef {import('./types.d.ts').ServerOptions} ServerOptions | ||
*/ | ||
/** | ||
@param {{ base?: string; body: string; icon?: 'list' | 'error'; title?: string }} data | ||
*/ | ||
async function htmlTemplate({ base, body, icon, title }) { | ||
const svgIcon = { list: FAVICON_LIST, error: FAVICON_ERROR }[String(icon)]; | ||
function htmlTemplate(data) { | ||
const { base, body, title } = data; | ||
const icon = { list: FAVICON_LIST, error: FAVICON_ERROR }[String(data.icon)]; | ||
return `<!doctype html> | ||
@@ -23,3 +14,3 @@ <html lang="en"> | ||
<meta name="viewport" content="width=device-width"> | ||
${svgIcon ? `<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;base64,${btoa(svgIcon)}">` : ''} | ||
${icon ? `<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;base64,${btoa(icon)}">` : ''} | ||
<style>${STYLES}</style> | ||
@@ -34,11 +25,5 @@ </head> | ||
} | ||
/** | ||
@param {{ status: number, urlPath: string }} data | ||
@returns {Promise<string>} | ||
*/ | ||
export function errorPage({ status, urlPath }) { | ||
const displayPath = decodeURIPathSegments(urlPath); | ||
export function errorPage(data) { | ||
const displayPath = decodeURIPathSegments(data.urlPath ?? data.url); | ||
const pathHtml = `<code class="filepath">${html(nl2sp(displayPath))}</code>`; | ||
const page = (title = '', desc = '') => { | ||
@@ -48,4 +33,5 @@ const body = `<h1>${html(title)}</h1>\n<p>${desc}</p>\n`; | ||
}; | ||
switch (status) { | ||
switch (data.status) { | ||
case 400: | ||
return page('400: Bad request', `Invalid request for ${pathHtml}`); | ||
case 403: | ||
@@ -63,17 +49,10 @@ return page('403: Forbidden', `Could not access ${pathHtml}`); | ||
} | ||
/** | ||
@param {{ urlPath: string; filePath: string; items: FSLocation[] }} data | ||
@param {Pick<ServerOptions, 'root' | 'ext'>} options | ||
@returns {Promise<string>} | ||
*/ | ||
export function dirListPage({ urlPath, filePath, items }, options) { | ||
const rootName = basename(options.root); | ||
export function dirListPage(data) { | ||
const { root, urlPath, filePath, items, ext } = data; | ||
const rootName = basename(root); | ||
const trimmedUrl = trimSlash(urlPath); | ||
const baseUrl = trimmedUrl ? `/${trimmedUrl}/` : '/'; | ||
const displayPath = decodeURIPathSegments(trimmedUrl ? `${rootName}/${trimmedUrl}` : rootName); | ||
const parentPath = dirname(filePath); | ||
const showParent = trimmedUrl !== ''; | ||
const sorted = [...items.filter((x) => isDirLike(x)), ...items.filter((x) => !isDirLike(x))]; | ||
@@ -83,6 +62,3 @@ if (showParent) { | ||
} | ||
// Make sure we have at least 2 items to put in each CSS column | ||
const maxCols = clamp(Math.ceil(sorted.length / 3), 1, 4); | ||
return htmlTemplate({ | ||
@@ -97,3 +73,3 @@ title: `Index of ${displayPath}`, | ||
<ul class="files" style="--max-col-count:${maxCols}"> | ||
${sorted.map((item) => renderListItem(item, { ext: options.ext, parentPath })).join('\n')} | ||
${sorted.map((item) => renderListItem({ item, ext, parentPath })).join('\n')} | ||
</ul> | ||
@@ -103,10 +79,6 @@ `.trim(), | ||
} | ||
/** | ||
@type {(item: FSLocation, options: { ext: ServerOptions['ext']; parentPath: string }) => string} | ||
*/ | ||
function renderListItem(item, { ext, parentPath }) { | ||
function renderListItem(data) { | ||
const { item, ext, parentPath } = data; | ||
const isDir = isDirLike(item); | ||
const isParent = isDir && item.filePath === parentPath; | ||
let icon = isDir ? 'icon-dir' : 'icon-file'; | ||
@@ -118,3 +90,2 @@ if (item.kind === 'link') icon += '-link'; | ||
let href = encodeURIComponent(name); | ||
if (isParent) { | ||
@@ -128,7 +99,5 @@ name = '..'; | ||
} else { | ||
// clean url: remove extension if possible | ||
const match = ext.find((e) => item.filePath.endsWith(e)); | ||
if (match) href = href.slice(0, href.length - match.length); | ||
} | ||
return [ | ||
@@ -143,4 +112,2 @@ `<li class="files-item">\n`, | ||
} | ||
/** @type {(path: string) => string} */ | ||
function renderBreadcrumbs(path) { | ||
@@ -161,31 +128,19 @@ const slash = '<span class="bc-sep">/</span>'; | ||
} | ||
/** @type {(item: FSLocation) => boolean} */ | ||
function isDirLike(item) { | ||
return item.kind === 'dir' || (item.kind === 'link' && item.target?.kind === 'dir'); | ||
} | ||
/** @type {(s: string) => string} */ | ||
function decodeURIPathSegment(s) { | ||
return decodeURIComponent(s).replaceAll('\\', '\\\\').replaceAll('/', '\\/'); | ||
} | ||
/** @type {(path: string) => string} */ | ||
function decodeURIPathSegments(path) { | ||
return path.split('/').map(decodeURIPathSegment).join('/'); | ||
} | ||
/** @type {(input: string) => string} */ | ||
function attr(str) { | ||
return escapeHtml(str, 'attr'); | ||
function attr(input) { | ||
return escapeHtml(input, 'attr'); | ||
} | ||
/** @type {(input: string) => string} */ | ||
function html(str) { | ||
return escapeHtml(str, 'text'); | ||
function html(input) { | ||
return escapeHtml(input, 'text'); | ||
} | ||
/** @type {(input: string) => string} */ | ||
function nl2sp(input) { | ||
return input.replace(/[\u{000A}-\u{000D}\u{2028}]/gu, ' '); | ||
} |
import { fwdSlash } from './utils.js'; | ||
export class PathMatcher { | ||
/** @type {Array<string | RegExp>} */ | ||
#positive = []; | ||
/** @type {Array<string | RegExp>} */ | ||
#negative = []; | ||
/** @type {boolean} */ | ||
#caseSensitive = true; | ||
/** | ||
@param {string[]} patterns | ||
@param {Partial<{ caseSensitive: boolean }>} [options] | ||
*/ | ||
constructor(patterns, options) { | ||
@@ -31,4 +20,2 @@ if (typeof options?.caseSensitive === 'boolean') { | ||
} | ||
/** @type {(filePath: string) => boolean} */ | ||
test(filePath) { | ||
@@ -42,4 +29,2 @@ if (this.#positive.length === 0) { | ||
} | ||
/** @type {(input: string) => string | RegExp | null} */ | ||
#parse(input) { | ||
@@ -58,4 +43,2 @@ if (this.#caseSensitive === false) { | ||
} | ||
/** @type {(pattern: string | RegExp, value: string) => boolean} */ | ||
#matchPattern(pattern, value) { | ||
@@ -73,4 +56,2 @@ if (this.#caseSensitive === false) { | ||
} | ||
/** @type {(segments: string[]) => string[]} */ | ||
#matchSegments(segments) { | ||
@@ -84,6 +65,8 @@ return segments.filter((segment) => { | ||
} | ||
data() { | ||
return { positive: this.#positive, negative: this.#negative }; | ||
return structuredClone({ | ||
positive: this.#positive, | ||
negative: this.#negative, | ||
}); | ||
} | ||
} |
import { isAbsolute, join } from 'node:path'; | ||
import { getIndex, getKind, getLocalPath, getRealpath, isReadable, isSubpath } from './fs-utils.js'; | ||
import { PathMatcher } from './path-matcher.js'; | ||
import { fwdSlash, trimSlash } from './utils.js'; | ||
/** | ||
@typedef {import('./types.d.ts').FSLocation} FSLocation | ||
@typedef {import('./types.d.ts').ServerOptions} ServerOptions | ||
*/ | ||
import { trimSlash } from './utils.js'; | ||
export class FileResolver { | ||
/** @type {string} */ | ||
#root; | ||
/** @type {string[]} */ | ||
#ext = []; | ||
/** @type {string[]} */ | ||
#dirFile = []; | ||
/** @type {boolean} */ | ||
#dirList = false; | ||
/** @type {PathMatcher} */ | ||
#excludeMatcher; | ||
/** @param {{root: string } & Partial<ServerOptions>} options */ | ||
constructor(options) { | ||
@@ -36,22 +18,24 @@ if (typeof options.root !== 'string') { | ||
this.#root = trimSlash(options.root, { end: true }); | ||
if (Array.isArray(options.ext)) this.#ext = options.ext; | ||
if (Array.isArray(options.dirFile)) this.#dirFile = options.dirFile; | ||
if (typeof options.dirList === 'boolean') this.#dirList = options.dirList; | ||
this.#excludeMatcher = new PathMatcher(options.exclude ?? [], { caseSensitive: true }); | ||
if (Array.isArray(options.ext)) { | ||
this.#ext = options.ext; | ||
} | ||
if (Array.isArray(options.dirFile)) { | ||
this.#dirFile = options.dirFile; | ||
} | ||
if (typeof options.dirList === 'boolean') { | ||
this.#dirList = options.dirList; | ||
} | ||
this.#excludeMatcher = new PathMatcher(options.exclude ?? [], { | ||
caseSensitive: true, | ||
}); | ||
} | ||
/** @param {string} url */ | ||
async find(url) { | ||
const { urlPath, filePath: targetPath } = resolveUrlPath(this.#root, url); | ||
/** @type {{status: number; urlPath: string; file?: FSLocation}} */ | ||
const result = { status: 404, urlPath }; | ||
if (targetPath == null) { | ||
return result; | ||
} | ||
// Locate file (following symlinks) | ||
let file = await this.locateFile(targetPath); | ||
if (file.kind === 'link') { | ||
allowedPath(filePath) { | ||
const localPath = getLocalPath(this.#root, filePath); | ||
if (localPath == null) return false; | ||
return this.#excludeMatcher.test(localPath) === false; | ||
} | ||
async find(localPath) { | ||
const targetPath = this.resolvePath(localPath); | ||
let file = targetPath != null ? await this.locateFile(targetPath) : null; | ||
if (file?.kind === 'link') { | ||
const realPath = await getRealpath(file.filePath); | ||
@@ -63,29 +47,18 @@ const real = realPath != null ? await this.locateFile(realPath) : null; | ||
} | ||
// We have a match | ||
if (file.kind === 'file' || file.kind === 'dir') { | ||
result.file = file; | ||
if (file?.kind === 'file' || file?.kind === 'dir') { | ||
const allowed = | ||
file.kind === 'dir' && !this.#dirList ? false : this.allowedPath(file.filePath); | ||
const readable = allowed && (await isReadable(file.filePath, file.kind)); | ||
result.status = allowed ? (readable ? 200 : 403) : 404; | ||
return { status: allowed ? (readable ? 200 : 403) : 404, file }; | ||
} | ||
return result; | ||
return { status: 404, file: null }; | ||
} | ||
/** @type {(dirPath: string) => Promise<FSLocation[]>} */ | ||
async index(dirPath) { | ||
if (!this.#dirList) return []; | ||
/** @type {FSLocation[]} */ | ||
const items = (await getIndex(dirPath)).filter( | ||
(item) => item.kind != null && this.allowedPath(item.filePath), | ||
); | ||
items.sort((a, b) => a.filePath.localeCompare(b.filePath)); | ||
return Promise.all( | ||
items.map(async (item) => { | ||
// resolve symlinks | ||
if (item.kind === 'link') { | ||
@@ -102,7 +75,3 @@ const filePath = await getRealpath(item.filePath); | ||
} | ||
/** | ||
@type {(filePath: string[]) => Promise<FSLocation | void>} | ||
*/ | ||
async locateAltFiles(filePaths) { | ||
async #locateAltFiles(filePaths) { | ||
for (const filePath of filePaths) { | ||
@@ -116,8 +85,2 @@ if (!this.withinRoot(filePath)) continue; | ||
} | ||
/** | ||
Locate a file or alternative files that can be served for a resource, | ||
using the config for extensions and index file lookup. | ||
@type {(filePath: string) => Promise<FSLocation>} | ||
*/ | ||
async locateFile(filePath) { | ||
@@ -127,36 +90,18 @@ if (!this.withinRoot(filePath)) { | ||
} | ||
const kind = await getKind(filePath); | ||
// Try alternates | ||
if (kind === 'dir' && this.#dirFile.length) { | ||
const paths = this.#dirFile.map((name) => join(filePath, name)); | ||
const match = await this.locateAltFiles(paths); | ||
const match = await this.#locateAltFiles(paths); | ||
if (match) return match; | ||
} else if (kind === null && this.#ext.length) { | ||
const paths = this.#ext.map((ext) => filePath + ext); | ||
const match = await this.locateAltFiles(paths); | ||
const match = await this.#locateAltFiles(paths); | ||
if (match) return match; | ||
} | ||
return { filePath, kind }; | ||
} | ||
/** @type {(filePath: string) => boolean} */ | ||
allowedPath(filePath) { | ||
const localPath = getLocalPath(this.#root, filePath); | ||
if (localPath == null) return false; | ||
return this.#excludeMatcher.test(localPath) === false; | ||
resolvePath(localPath) { | ||
const filePath = join(this.#root, localPath); | ||
return this.withinRoot(filePath) ? trimSlash(filePath, { end: true }) : null; | ||
} | ||
/** @type {(urlPath: string | null) => string | null} */ | ||
urlToTargetPath(urlPath) { | ||
if (urlPath && urlPath.startsWith('/')) { | ||
const filePath = join(this.#root, decodeURIComponent(urlPath)); | ||
return trimSlash(filePath, { end: true }); | ||
} | ||
return null; | ||
} | ||
/** @type {(filePath: string) => boolean} */ | ||
withinRoot(filePath) { | ||
@@ -166,26 +111,1 @@ return isSubpath(this.#root, filePath); | ||
} | ||
/** @type {(urlPath: string) => boolean} */ | ||
export function isValidUrlPath(urlPath) { | ||
if (urlPath === '/') return true; | ||
if (!urlPath.startsWith('/') || urlPath.includes('//')) return false; | ||
for (const s of trimSlash(urlPath).split('/')) { | ||
const d = decodeURIComponent(s); | ||
if (d === '.' || d === '..') return false; | ||
if (s.includes('?') || s.includes('#')) return false; | ||
if (d.includes('/') || d.includes('\\')) return false; | ||
} | ||
return true; | ||
} | ||
/** @type {(root: url, url: string) => {urlPath: string; filePath: string | null}} */ | ||
export function resolveUrlPath(root, url) { | ||
try { | ||
const urlPath = fwdSlash(new URL(url, 'http://localhost/').pathname) ?? '/'; | ||
const filePath = isValidUrlPath(urlPath) | ||
? trimSlash(join(root, decodeURIComponent(urlPath)), { end: true }) | ||
: null; | ||
return { urlPath, filePath }; | ||
} catch {} | ||
return { urlPath: url, filePath: null }; | ||
} |
import { env, versions } from 'node:process'; | ||
/** @type {(value: number, min: number, max: number) => number} */ | ||
export function clamp(value, min, max) { | ||
@@ -8,4 +6,2 @@ if (typeof value !== 'number') value = min; | ||
} | ||
/** @type {(input: string, context?: 'text' | 'attr') => string} */ | ||
export function escapeHtml(input, context = 'text') { | ||
@@ -17,6 +13,3 @@ if (typeof input !== 'string') return ''; | ||
} | ||
/** @type {() => { (msg: string): void; list: string[] }} */ | ||
export function errorList() { | ||
/** @type {string[]} */ | ||
const list = []; | ||
@@ -27,14 +20,8 @@ const fn = (msg = '') => list.push(msg); | ||
} | ||
/** @type {(input: string) => string} */ | ||
export function fwdSlash(input = '') { | ||
return input.replace(/\\/g, '/').replace(/\/{2,}/g, '/'); | ||
} | ||
/** @type {(key: string) => string} */ | ||
export function getEnv(key) { | ||
return env[key] ?? ''; | ||
} | ||
/** @type {() => 'bun' | 'deno' | 'node' | 'webcontainer'} */ | ||
export const getRuntime = once(() => { | ||
@@ -46,10 +33,7 @@ if (versions.bun && globalThis.Bun) return 'bun'; | ||
}); | ||
/** @type {(name: string) => string} */ | ||
export function headerCase(name) { | ||
return name.replace(/((^|\b|_)[a-z])/g, (s) => s.toUpperCase()); | ||
} | ||
/** @type {(address: string) => boolean} */ | ||
export function isPrivateIPv4(address = '') { | ||
export function isPrivateIPv4(address) { | ||
if (!address) return false; | ||
const bytes = address.split('.').map(Number); | ||
@@ -61,12 +45,7 @@ if (bytes.length !== 4) return false; | ||
return ( | ||
// 10/8 | ||
bytes[0] === 10 || | ||
// 172.16/12 | ||
(bytes[0] === 172 && bytes[1] >= 16 && bytes[1] < 32) || | ||
// 192.168/16 | ||
(bytes[0] === 192 && bytes[1] === 168) | ||
); | ||
} | ||
/** @type {(start: number, end: number, limit?: number) => number[]} */ | ||
export function intRange(start, end, limit = 1_000) { | ||
@@ -82,9 +61,3 @@ for (const [key, val] of Object.entries({ start, end, limit })) { | ||
} | ||
/** | ||
Cache a function's result after the first call | ||
@type {<Result>(fn: () => Result) => () => Result} | ||
*/ | ||
export function once(fn) { | ||
/** @type {ReturnType<fn>} */ | ||
let value; | ||
@@ -96,19 +69,16 @@ return () => { | ||
} | ||
/** | ||
@type {(input: string, options?: { start?: boolean; end?: boolean }) => string} | ||
*/ | ||
export function trimSlash(input = '', { start, end } = { start: true, end: true }) { | ||
if (start === true) input = input.replace(/^[\/\\]/, ''); | ||
if (end === true) input = input.replace(/[\/\\]$/, ''); | ||
export function trimSlash(input = '', config = { start: true, end: true }) { | ||
if (config.start === true) input = input.replace(/^[\/\\]/, ''); | ||
if (config.end === true) input = input.replace(/[\/\\]$/, ''); | ||
return input; | ||
} | ||
export function withResolvers() { | ||
/** @type {{ resolve: (value?: any) => void; reject: (reason?: any) => void }} */ | ||
let resolvers = { resolve: () => {}, reject: () => {} }; | ||
const promise = new Promise((resolve, reject) => { | ||
resolvers = { resolve, reject }; | ||
const noop = () => {}; | ||
let resolve = noop; | ||
let reject = noop; | ||
const promise = new Promise((res, rej) => { | ||
resolve = res; | ||
reject = rej; | ||
}); | ||
return { promise, ...resolvers }; | ||
return { promise, resolve, reject }; | ||
} |
{ | ||
"name": "servitsy", | ||
"version": "0.4.3", | ||
"version": "0.4.4", | ||
"license": "MIT", | ||
@@ -24,2 +24,5 @@ "description": "Small, local HTTP server for static files", | ||
"main": "./lib/index.js", | ||
"bin": { | ||
"servitsy": "bin/servitsy.js" | ||
}, | ||
"exports": { | ||
@@ -30,4 +33,5 @@ ".": { | ||
}, | ||
"bin": { | ||
"servitsy": "bin/servitsy.js" | ||
"imports": { | ||
"#src/*.js": "./src/*.js", | ||
"#types": "./src/types.d.ts" | ||
}, | ||
@@ -41,7 +45,7 @@ "files": [ | ||
"scripts": { | ||
"prepack": "npm run build && npm run typecheck && npm test", | ||
"build": "node scripts/bundle.js", | ||
"format": "prettier --write '**/*.{js,css}' '**/*config*.json'", | ||
"test": "node --test --test-reporter=spec", | ||
"typecheck": "tsc -p jsconfig.json && tsc -p test/jsconfig.json" | ||
"prepack": "npm run build && npm test", | ||
"build": "node scripts/prebuild.js && tsc -p tsconfig.json --listEmittedFiles && prettier --ignore-path='' --write 'lib/*.js'", | ||
"format": "prettier --write '**/*.{css,js,ts}' '**/*config*.json'", | ||
"test": "vitest --run test/*.test.ts", | ||
"typecheck": "tsc -p tsconfig.json --noEmit && tsc -p test/tsconfig.json --noEmit" | ||
}, | ||
@@ -53,4 +57,5 @@ "devDependencies": { | ||
"prettier": "^3.3.3", | ||
"typescript": "~5.6.3" | ||
"typescript": "~5.6.3", | ||
"vitest": "^2.1.5" | ||
} | ||
} |
@@ -5,3 +5,3 @@ # servitsy | ||
- **Small:** no dependencies, 26 kilobytes gzipped. | ||
- **Small:** no dependencies, 22 kilobytes gzipped. | ||
- **Local:** designed for local development workflows. | ||
@@ -68,3 +68,3 @@ - **Static:** serves files and directory listings. | ||
| ------------- | ------- | ------------ | --------------- | | ||
| [servitsy] | 0.4.3 | 0 | 116 kB | | ||
| [servitsy] | 0.4.4 | 0 | 108 kB | | ||
| [servor] | 4.0.2 | 0 | 144 kB | | ||
@@ -71,0 +71,0 @@ | [sirv-cli] | 3.0.0 | 12 | 396 kB | |
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
65948
6
18
2018