Comparing version 0.4.0 to 0.4.1
@@ -9,19 +9,12 @@ import { CLI_OPTIONS, PORTS_CONFIG } from './constants.js'; | ||
@typedef {import('./types.d.ts').ServerOptions} ServerOptions | ||
**/ | ||
*/ | ||
export class CLIArgs { | ||
/** | ||
* @type {Array<[string, string]>} | ||
*/ | ||
/** @type {Array<[string, string]>} */ | ||
#map = []; | ||
/** | ||
* @type {string[]} | ||
*/ | ||
/** @type {string[]} */ | ||
#list = []; | ||
/** | ||
* @param {string | string[]} keys | ||
* @returns {(entry: [string, string]) => boolean} | ||
*/ | ||
/** @type {(keys: string | string[]) => (entry: [string, string]) => boolean} */ | ||
#mapFilter(keys) { | ||
@@ -31,5 +24,3 @@ return (entry) => (typeof keys === 'string' ? keys === entry[0] : keys.includes(entry[0])); | ||
/** | ||
* @param {string[]} args | ||
*/ | ||
/** @param {string[]} args */ | ||
constructor(args) { | ||
@@ -58,6 +49,3 @@ const optionPattern = /^-{1,2}[\w]/; | ||
/** | ||
* @param {string | null} key | ||
* @param {string} value | ||
*/ | ||
/** @type {(key: string | null, value: string) => void} */ | ||
add(key, value) { | ||
@@ -71,13 +59,8 @@ if (key == null) { | ||
/** | ||
* Check if args contain a value for one or several option names, | ||
* or at a specific positional index. | ||
* @param {number | string | string[]} keys | ||
* @returns {boolean} | ||
*/ | ||
has(keys) { | ||
if (typeof keys === 'number') { | ||
return typeof this.#list.at(keys) === 'string'; | ||
/** @type {(query: number | string | string[]) => boolean} */ | ||
has(query) { | ||
if (typeof query === 'number') { | ||
return typeof this.#list.at(query) === 'string'; | ||
} else { | ||
return this.#map.some(this.#mapFilter(keys)); | ||
return this.#map.some(this.#mapFilter(query)); | ||
} | ||
@@ -87,7 +70,5 @@ } | ||
/** | ||
* Get the last value for one or several option names, | ||
* or a specific positional index. | ||
* @param {number | string | string[]} query | ||
* @returns {string | undefined} | ||
*/ | ||
Get the last value for one or several option names, or a specific positional index. | ||
@type {(query: number | string | string[]) => string | undefined} | ||
*/ | ||
get(query) { | ||
@@ -102,7 +83,6 @@ if (typeof query === 'number') { | ||
/** | ||
* Get mapped values for one or several option names. | ||
* Values are merged in order of appearance. | ||
* @param {string | string[]} query | ||
* @returns {string[]} | ||
*/ | ||
Get mapped values for one or several option names. | ||
Values are merged in order of appearance. | ||
@type {(query: string | string[]) => string[]} query | ||
*/ | ||
all(query) { | ||
@@ -112,6 +92,2 @@ return this.#map.filter(this.#mapFilter(query)).map((entry) => entry[1]); | ||
/** | ||
* Get the names of all mapped options. | ||
* @returns {string[]} | ||
*/ | ||
keys() { | ||
@@ -142,5 +118,3 @@ /** @type {string[]} */ | ||
/** | ||
* @type {(value: string) => string} | ||
*/ | ||
/** @type {(value: string) => string} */ | ||
function normalizeExt(value = '') { | ||
@@ -153,7 +127,3 @@ if (typeof value === 'string' && value.length && !value.startsWith('.')) { | ||
/** | ||
* @param {CLIArgs} args | ||
* @param {{ error: ErrorList }} [context] | ||
* @returns {Partial<ServerOptions>} | ||
*/ | ||
/** @type {(args: CLIArgs, context?: { error: ErrorList }) => Partial<ServerOptions>} */ | ||
export function parseArgs(args, context) { | ||
@@ -235,6 +205,3 @@ const invalid = (optName = '', input = '') => { | ||
/** | ||
* @param {string} input | ||
* @returns {HttpHeaderRule | undefined} | ||
*/ | ||
/** @type {(input: string) => HttpHeaderRule | undefined} */ | ||
export function parseHeaders(input) { | ||
@@ -279,6 +246,3 @@ input = input.trim(); | ||
/** | ||
* @param {string} input | ||
* @returns {number[] | undefined} | ||
*/ | ||
/** @type {(input: string) => number[] | undefined} */ | ||
export function parsePort(input) { | ||
@@ -327,6 +291,3 @@ const matches = input.match(/^(?<start>\d{1,})(?<end>\+|-\d{1,})?$/); | ||
/** | ||
* @param {CLIArgs} args | ||
* @returns {string[]} | ||
*/ | ||
/** @type {(args: CLIArgs) => string[]} */ | ||
export function unknownArgs(args) { | ||
@@ -333,0 +294,0 @@ const known = Object.values(CLI_OPTIONS).flatMap((spec) => { |
@@ -8,3 +8,3 @@ import { homedir, networkInterfaces } from 'node:os'; | ||
import { CLI_OPTIONS, HOSTS_LOCAL, HOSTS_WILDCARD } from './constants.js'; | ||
import { readPkgJson } from './fs-utils.js'; | ||
import { checkDirAccess, pkgFilePath, readPkgJson } from './fs-utils.js'; | ||
import { color, logger, requestLogLine } from './logger.js'; | ||
@@ -16,10 +16,9 @@ import { serverOptions } from './options.js'; | ||
/** | ||
@typedef {import('./types.d.ts').OptionName} OptionName | ||
@typedef {import('./types.d.ts').OptionSpec} OptionSpec | ||
@typedef {import('./types.d.ts').ServerOptions} ServerOptions | ||
**/ | ||
*/ | ||
/** | ||
* Run servitsy with configuration from command line arguments. | ||
*/ | ||
Start servitsy with configuration from command line arguments. | ||
*/ | ||
export async function run() { | ||
@@ -40,10 +39,12 @@ const args = new CLIArgs(argv.slice(2)); | ||
const error = errorList(); | ||
const options = serverOptions( | ||
{ | ||
root: '', | ||
...parseArgs(args, { error }), | ||
}, | ||
{ error }, | ||
); | ||
const userOptions = parseArgs(args, { error }); | ||
const options = serverOptions({ root: '', ...userOptions }, { error }); | ||
await checkDirAccess(options.root, { error }); | ||
// check access to assets needed by server pages, | ||
// to trigger a permission prompt before the server starts | ||
if (getRuntime() === 'deno') { | ||
await checkDirAccess(pkgFilePath('assets')); | ||
} | ||
if (error.list.length) { | ||
@@ -76,5 +77,3 @@ logger.error(...error.list); | ||
/** | ||
* @param {ServerOptions} options | ||
*/ | ||
/** @param {ServerOptions} options */ | ||
constructor(options) { | ||
@@ -94,3 +93,4 @@ this.#options = options; | ||
this.#server.on('listening', () => { | ||
logger.write('header', this.headerInfo(), { top: 1, bottom: 1 }); | ||
const info = this.headerInfo(); | ||
if (info) logger.write('header', info, { top: 1, bottom: 1 }); | ||
}); | ||
@@ -180,8 +180,6 @@ } | ||
/** | ||
* @param {NodeJS.ErrnoException & {hostname?: string}} error | ||
*/ | ||
/** @type {(error: NodeJS.ErrnoException & {hostname?: string}) => void} */ | ||
#onServerError(error) { | ||
// Try restarting with the next port | ||
if (error.syscall === 'listen' && error.code === 'EADDRINUSE') { | ||
if (error.code === 'EADDRINUSE') { | ||
const { value: nextPort } = this.#portIterator.next(); | ||
@@ -207,3 +205,3 @@ const { ports } = this.#options; | ||
// Handle other errors | ||
if (error.syscall === 'getaddrinfo' && error.code === 'ENOTFOUND') { | ||
if (error.code === 'ENOTFOUND') { | ||
logger.error(`host not found: '${error.hostname}'`); | ||
@@ -221,3 +219,3 @@ } else { | ||
/** @type {OptionName[]} */ | ||
/** @type {Array<keyof CLI_OPTIONS>} */ | ||
const optionsOrder = [ | ||
@@ -280,5 +278,5 @@ 'help', | ||
/** | ||
* @param {{ configuredHost: string; currentHost: string; networkAddress?: string }} address | ||
* @returns {{ local: string; network?: string }} | ||
*/ | ||
@param {{ configuredHost: string; currentHost: string; networkAddress?: string }} address | ||
@returns {{ local: string; network?: string }} | ||
*/ | ||
function displayHosts({ configuredHost, currentHost, networkAddress }) { | ||
@@ -300,6 +298,5 @@ const isLocalhost = (value = '') => HOSTS_LOCAL.includes(value); | ||
/** | ||
* Replace the home dir with '~' in path | ||
* @param {string} root | ||
* @returns {string} | ||
*/ | ||
Replace the home dir with '~' in path | ||
@type {(root: string) => string} | ||
*/ | ||
function displayRoot(root) { | ||
@@ -306,0 +303,0 @@ if ( |
@@ -1,8 +0,1 @@ | ||
/** | ||
@typedef {import('./types.d.ts').OptionName} OptionName | ||
@typedef {import('./types.d.ts').OptionSpec} OptionSpec | ||
@typedef {import('./types.d.ts').ServerOptions} ServerOptions | ||
@typedef {import('./types.d.ts').PortsConfig} PortsConfig | ||
**/ | ||
/** @type {string[]} */ | ||
@@ -14,3 +7,3 @@ export const HOSTS_LOCAL = ['localhost', '127.0.0.1', '::1']; | ||
/** @type {PortsConfig} */ | ||
/** @type {import('./types.d.ts').PortsConfig} */ | ||
export const PORTS_CONFIG = { | ||
@@ -27,5 +20,3 @@ initial: 8080, | ||
/** | ||
* @type {Omit<ServerOptions, 'root'>} | ||
*/ | ||
/** @type {Omit<import('./types.d.ts').ServerOptions, 'root'>} */ | ||
export const DEFAULT_OPTIONS = { | ||
@@ -43,18 +34,3 @@ host: HOSTS_WILDCARD.v6, | ||
/** | ||
* @type {Omit<ServerOptions, 'root'>} | ||
*/ | ||
export const MINIMAL_OPTIONS = { | ||
host: HOSTS_WILDCARD.v6, | ||
ports: [8080], | ||
gzip: false, | ||
cors: false, | ||
headers: [], | ||
dirList: false, | ||
dirFile: [], | ||
ext: [], | ||
exclude: [], | ||
}; | ||
/** @type {Record<OptionName, OptionSpec>} */ | ||
/** @type {import('./types.d.ts').OptionSpecs} */ | ||
export const CLI_OPTIONS = { | ||
@@ -61,0 +37,0 @@ cors: { |
@@ -13,3 +13,3 @@ import { basename, extname } from 'node:path'; | ||
}} TypeMap | ||
**/ | ||
*/ | ||
@@ -79,5 +79,3 @@ const strarr = (s = '') => s.trim().split(/\s+/); | ||
/** | ||
* @type {TypeMap} | ||
*/ | ||
/** @type {TypeMap} */ | ||
const BIN_TYPES = { | ||
@@ -155,5 +153,3 @@ default: 'application/octet-stream', | ||
/** | ||
* @param {string | null} [charset] | ||
*/ | ||
/** @param {string | null} [charset] */ | ||
constructor(charset = DEFAULT_CHARSET) { | ||
@@ -190,7 +186,3 @@ this.charset = charset; | ||
/** | ||
* @param {string} filePath | ||
* @param {string|null} [charset] | ||
* @returns {TypeResult} | ||
*/ | ||
/** @type {(filePath: string, charset?: string | null) => TypeResult} */ | ||
export function typeForFilePath(filePath, charset) { | ||
@@ -222,10 +214,10 @@ const result = new TypeResult(charset); | ||
/** | ||
* @param {FileHandle} fileHandle | ||
* @param {string | null} [charset] | ||
* @returns {Promise<TypeResult>} | ||
*/ | ||
export async function typeForFile(fileHandle, charset) { | ||
@param {FileHandle} handle | ||
@param {string | null} [charset] | ||
@returns {Promise<TypeResult>} | ||
*/ | ||
export async function typeForFile(handle, charset) { | ||
const result = new TypeResult(charset); | ||
try { | ||
const { buffer, bytesRead } = await fileHandle.read({ | ||
const { buffer, bytesRead } = await handle.read({ | ||
buffer: new Uint8Array(1500), | ||
@@ -245,8 +237,8 @@ offset: 0, | ||
/** | ||
* @param {{ filePath?: string; fileHandle?: import('node:fs/promises').FileHandle }} data | ||
* @returns {Promise<TypeResult>} | ||
*/ | ||
export async function getContentType({ filePath, fileHandle }) { | ||
if (filePath) { | ||
const result = typeForFilePath(filePath); | ||
@param {{ path?: string; handle?: FileHandle }} file | ||
@returns {Promise<TypeResult>} | ||
*/ | ||
export async function getContentType({ path, handle }) { | ||
if (path) { | ||
const result = typeForFilePath(path); | ||
if (result.group !== 'unknown') { | ||
@@ -256,4 +248,4 @@ return result; | ||
} | ||
if (fileHandle) { | ||
const result = await typeForFile(fileHandle); | ||
if (handle) { | ||
const result = await typeForFile(handle); | ||
return result; | ||
@@ -265,7 +257,5 @@ } | ||
/** | ||
* Using the algorithm from: | ||
* https://mimesniff.spec.whatwg.org/#sniffing-a-mislabeled-binary-resource | ||
* @param {Uint8Array} bytes | ||
* @returns {boolean} | ||
*/ | ||
https://mimesniff.spec.whatwg.org/#sniffing-a-mislabeled-binary-resource | ||
@type {(bytes: Uint8Array) => boolean} | ||
*/ | ||
export function isBinHeader(bytes) { | ||
@@ -296,6 +286,5 @@ const limit = Math.min(bytes.length, 2000); | ||
/** | ||
* https://mimesniff.spec.whatwg.org/#binary-data-byte | ||
* @param {number} int | ||
* @returns {boolean} | ||
*/ | ||
https://mimesniff.spec.whatwg.org/#binary-data-byte | ||
@type {(int: number) => boolean} | ||
*/ | ||
export function isBinDataByte(int) { | ||
@@ -302,0 +291,0 @@ if (int >= 0 && int <= 0x1f) { |
@@ -1,2 +0,2 @@ | ||
import { access, constants, lstat, readdir, readFile, realpath } from 'node:fs/promises'; | ||
import { access, constants, lstat, readdir, readFile, realpath, stat } from 'node:fs/promises'; | ||
import { join } from 'node:path'; | ||
@@ -8,7 +8,31 @@ import { fileURLToPath } from 'node:url'; | ||
@typedef {import('./types.d.ts').FSEntryKind} FSEntryKind | ||
**/ | ||
@typedef {import('./types.d.ts').ErrorList} ErrorList | ||
*/ | ||
/** | ||
* @type {(moduleUrl: URL | string) => string} | ||
*/ | ||
/** @type {(dirPath: string, context?: { error: ErrorList }) => Promise<boolean>} */ | ||
export async function checkDirAccess(dirPath, context) { | ||
let msg = ''; | ||
try { | ||
const stats = await stat(dirPath); | ||
if (stats.isDirectory()) { | ||
// needs r-x permissions to access contents of the directory | ||
await access(dirPath, constants.R_OK | constants.X_OK); | ||
return true; | ||
} else { | ||
msg = `not a directory: ${dirPath}`; | ||
} | ||
} catch (/** @type {any} */ err) { | ||
if (err.code === 'ENOENT') { | ||
msg = `not a directory: ${dirPath}`; | ||
} else if (err.code === 'EACCES') { | ||
msg = `permission denied: ${dirPath}`; | ||
} else { | ||
msg = err.toString(); | ||
} | ||
} | ||
if (msg) context?.error(msg); | ||
return false; | ||
} | ||
/** @type {(moduleUrl: URL | string) => string} */ | ||
export function moduleDirname(moduleUrl) { | ||
@@ -18,6 +42,3 @@ return fileURLToPath(new URL('.', moduleUrl)); | ||
/** | ||
* @param {string} dirPath | ||
* @returns {Promise<FSEntryBase[]>} | ||
*/ | ||
/** @type {(dirPath: string) => Promise<FSEntryBase[]>} */ | ||
export async function getIndex(dirPath) { | ||
@@ -35,6 +56,3 @@ try { | ||
/** | ||
* @param {string} filePath | ||
* @returns {Promise<FSEntryKind | null>} | ||
*/ | ||
/** @type {(filePath: string) => Promise<FSEntryKind | null>} */ | ||
export async function getKind(filePath) { | ||
@@ -49,6 +67,3 @@ try { | ||
/** | ||
* @param {string} filePath | ||
* @returns {Promise<string | null>} | ||
*/ | ||
/** @type {(filePath: string) => Promise<string | null>} */ | ||
export async function getRealpath(filePath) { | ||
@@ -63,6 +78,3 @@ try { | ||
/** | ||
* @param {string} filePath | ||
* @param {FSEntryKind | null} [kind] | ||
*/ | ||
/** @type {(filePath: string, kind?: FSEntryKind | null) => Promise<boolean>} */ | ||
export async function isReadable(filePath, kind) { | ||
@@ -82,14 +94,13 @@ if (kind === undefined) { | ||
/** | ||
* @param {string} localPath | ||
* @returns {Promise<string>} | ||
*/ | ||
/** @type {(localPath: string) => string} */ | ||
export function pkgFilePath(localPath) { | ||
return join(moduleDirname(import.meta.url), '..', localPath); | ||
} | ||
/** @type {(localPath: string) => Promise<string>} */ | ||
export async function readPkgFile(localPath) { | ||
const fullPath = join(moduleDirname(import.meta.url), '..', localPath); | ||
return readFile(fullPath, { encoding: 'utf8' }); | ||
return readFile(pkgFilePath(localPath), { encoding: 'utf8' }); | ||
} | ||
/** | ||
* @returns {Promise<Record<string, any>>} | ||
*/ | ||
/** @type {() => Promise<Record<string, any>>} */ | ||
export async function readPkgJson() { | ||
@@ -100,6 +111,3 @@ const raw = await readPkgFile('package.json'); | ||
/** | ||
* @param {import('node:fs').Dirent | import('node:fs').StatsBase<any>} stats | ||
* @returns {FSEntryKind | null} | ||
*/ | ||
/** @type {(stats: import('node:fs').Dirent | import('node:fs').StatsBase<any>) => FSEntryKind | null} */ | ||
export function statsKind(stats) { | ||
@@ -106,0 +114,0 @@ if (stats.isSymbolicLink()) return 'link'; |
@@ -15,3 +15,3 @@ import { release } from 'node:os'; | ||
}} LogItem | ||
**/ | ||
*/ | ||
@@ -57,6 +57,4 @@ export class ColorUtils { | ||
/** | ||
* @param {LogItem | null} prev | ||
* @param {LogItem} next | ||
* @returns {string} | ||
*/ | ||
@type {(prev: LogItem | null, next: LogItem) => string} | ||
*/ | ||
#withPadding(prev, { group, text, padding }) { | ||
@@ -79,7 +77,4 @@ const maxPad = 4; | ||
/** | ||
* @param {LogItem['group']} group | ||
* @param {string | string[]} [data] | ||
* @param {LogItem['padding']} [padding] | ||
* @returns {Promise<void>} | ||
*/ | ||
@type {(group: LogItem['group'], data: string | string[], padding?: LogItem['padding']) => Promise<void>} | ||
*/ | ||
async write(group, data = '', padding = { top: 0, bottom: 0 }) { | ||
@@ -113,4 +108,4 @@ const item = { | ||
/** | ||
* @param {Array<string | Error>} errors | ||
*/ | ||
@type {(...errors: Array<string | Error>) => void} | ||
*/ | ||
error(...errors) { | ||
@@ -127,6 +122,3 @@ this.write( | ||
/** | ||
* @param {ReqResMeta} data | ||
* @returns {string} | ||
*/ | ||
/** @type {(data: ReqResMeta) => string} */ | ||
export function requestLogLine({ startedAt, endedAt, status, method, urlPath, file, error }) { | ||
@@ -166,7 +158,3 @@ const { brackets, style } = color; | ||
/** | ||
* @param {string} basePath | ||
* @param {string} fullPath | ||
* @returns {string | undefined} | ||
*/ | ||
/** @type {(basePath: string, fullPath: string) => string | undefined} */ | ||
function pathSuffix(basePath, fullPath) { | ||
@@ -180,6 +168,3 @@ if (basePath === fullPath) { | ||
/** | ||
* @param {string} input | ||
* @returns {string} | ||
*/ | ||
/** @type {(input: string) => string} */ | ||
export function stripStyle(input) { | ||
@@ -193,7 +178,5 @@ if (typeof input === 'string' && input.includes('\x1b[')) { | ||
/** | ||
* Basic implementation of 'node:util' styleText to support Node 18 + Deno. | ||
* @param {string | string[]} format | ||
* @param {string} text | ||
* @returns {string} | ||
*/ | ||
Basic implementation of 'node:util' styleText to support Node 18 + Deno. | ||
@type {(format: string | string[], text: string) => string} | ||
*/ | ||
export function styleText(format, text) { | ||
@@ -211,5 +194,3 @@ let before = ''; | ||
/** | ||
* @type {() => boolean} | ||
*/ | ||
/** @type {() => boolean} */ | ||
function supportsColor() { | ||
@@ -216,0 +197,0 @@ if (typeof globalThis.Deno?.noColor === 'boolean') { |
@@ -1,6 +0,4 @@ | ||
import { accessSync, constants as fsConstants, statSync } from 'node:fs'; | ||
import { resolve } from 'node:path'; | ||
import { isAbsolute, resolve } from 'node:path'; | ||
import { DEFAULT_OPTIONS, MINIMAL_OPTIONS, PORTS_CONFIG } from './constants.js'; | ||
import { intRange } from './utils.js'; | ||
import { DEFAULT_OPTIONS, PORTS_CONFIG } from './constants.js'; | ||
@@ -10,11 +8,7 @@ /** | ||
@typedef {import('./types.d.ts').HttpHeaderRule} HttpHeaderRule | ||
@typedef {import('./types.d.ts').OptionName} OptionName | ||
@typedef {import('./types.d.ts').PortsConfig} PortsConfig | ||
@typedef {import('./types.d.ts').ServerOptions} ServerOptions | ||
**/ | ||
*/ | ||
export class OptionsValidator { | ||
/** | ||
* @param {ErrorList} [errorList] | ||
*/ | ||
/** @param {ErrorList} [errorList] */ | ||
constructor(errorList) { | ||
@@ -25,4 +19,4 @@ this.errorList = errorList; | ||
/** | ||
* @type {<T = string>(input: T[] | undefined, filterFn: (item: T) => boolean) => T[] | undefined} | ||
*/ | ||
@type {<T = string>(input: T[] | undefined, filterFn: (item: T) => boolean) => T[] | undefined} | ||
*/ | ||
#array(input, filterFn) { | ||
@@ -36,4 +30,4 @@ if (!Array.isArray(input)) return; | ||
/** | ||
* @type {(optName: string, input?: boolean) => boolean | undefined} | ||
*/ | ||
@type {(optName: string, input?: boolean) => boolean | undefined} | ||
*/ | ||
#bool(optName, input) { | ||
@@ -49,5 +43,3 @@ if (typeof input === 'undefined') return; | ||
/** | ||
* @type {(input?: boolean) => boolean | undefined} | ||
*/ | ||
/** @type {(input?: boolean) => boolean | undefined} */ | ||
cors(input) { | ||
@@ -57,5 +49,3 @@ return this.#bool('cors', input); | ||
/** | ||
* @type {(input?: string[]) => string[] | undefined} | ||
*/ | ||
/** @type {(input?: string[]) => string[] | undefined} */ | ||
dirFile(input) { | ||
@@ -69,5 +59,3 @@ return this.#array(input, (item) => { | ||
/** | ||
* @type {(input?: boolean) => boolean | undefined} | ||
*/ | ||
/** @type {(input?: boolean) => boolean | undefined} */ | ||
dirList(input) { | ||
@@ -77,5 +65,3 @@ return this.#bool('dirList', input); | ||
/** | ||
* @type {(input?: string[]) => string[] | undefined} | ||
*/ | ||
/** @type {(input?: string[]) => string[] | undefined} */ | ||
exclude(input) { | ||
@@ -89,5 +75,3 @@ return this.#array(input, (item) => { | ||
/** | ||
* @type {(input?: string[]) => string[] | undefined} | ||
*/ | ||
/** @type {(input?: string[]) => string[] | undefined} */ | ||
ext(input) { | ||
@@ -101,5 +85,3 @@ return this.#array(input, (item) => { | ||
/** | ||
* @type {(input?: boolean) => boolean | undefined} | ||
*/ | ||
/** @type {(input?: boolean) => boolean | undefined} */ | ||
gzip(input) { | ||
@@ -109,5 +91,3 @@ return this.#bool('gzip', input); | ||
/** | ||
* @type {(input?: HttpHeaderRule[]) => HttpHeaderRule[] | undefined} | ||
*/ | ||
/** @type {(input?: HttpHeaderRule[]) => HttpHeaderRule[] | undefined} */ | ||
headers(input) { | ||
@@ -121,5 +101,3 @@ return this.#array(input, (rule) => { | ||
/** | ||
* @type {(input?: string) => string | undefined} | ||
*/ | ||
/** @type {(input?: string) => string | undefined} */ | ||
host(input) { | ||
@@ -131,5 +109,3 @@ if (typeof input !== 'string') return; | ||
/** | ||
* @type {(input?: number[]) => number[] | undefined} | ||
*/ | ||
/** @type {(input?: number[]) => number[] | undefined} */ | ||
ports(input) { | ||
@@ -143,31 +119,10 @@ if (!Array.isArray(input) || input.length === 0) return; | ||
/** | ||
* @type {(input?: string) => string} | ||
*/ | ||
root(value) { | ||
const root = resolve(typeof value === 'string' ? value : ''); | ||
try { | ||
const stats = statSync(root); | ||
if (stats.isDirectory()) { | ||
// needs r-x permissions to access contents of the directory | ||
accessSync(root, fsConstants.R_OK | fsConstants.X_OK); | ||
} else { | ||
this.#error(`not a directory: ${root}`); | ||
} | ||
} catch (/** @type {any} */ err) { | ||
if (err.code === 'ENOENT') { | ||
this.#error(`not a directory: ${root}`); | ||
} else if (err.code === 'EACCES') { | ||
this.#error(`permission denied: ${root}`); | ||
} else { | ||
this.#error(err.toString()); | ||
} | ||
} | ||
return root; | ||
/** @type {(input?: string) => string} */ | ||
root(input) { | ||
const value = typeof input === 'string' ? input : ''; | ||
return isAbsolute(value) ? value : resolve(value); | ||
} | ||
} | ||
/** | ||
* @type {(input: unknown) => input is string[]} | ||
*/ | ||
/** @type {(input: unknown) => input is string[]} */ | ||
export function isStringArray(input) { | ||
@@ -177,5 +132,3 @@ return Array.isArray(input) && input.every((item) => typeof item === 'string'); | ||
/** | ||
* @type {(input: string) => boolean} | ||
*/ | ||
/** @type {(input: string) => boolean} */ | ||
export function isValidExt(input) { | ||
@@ -186,5 +139,3 @@ if (typeof input !== 'string' || !input) return false; | ||
/** | ||
* @type {(name: string) => boolean} | ||
*/ | ||
/** @type {(name: string) => boolean} */ | ||
export function isValidHeader(name) { | ||
@@ -194,5 +145,3 @@ return typeof name === 'string' && /^[a-z\d\-\_]+$/i.test(name); | ||
/** | ||
* @type {(value: any) => value is HttpHeaderRule} | ||
*/ | ||
/** @type {(value: any) => value is HttpHeaderRule} */ | ||
export function isValidHeaderRule(value) { | ||
@@ -218,6 +167,6 @@ const include = value?.include; | ||
/** | ||
* Checking that all characters are valid for a domain or ip, | ||
* as a usability nicety to catch obvious errors | ||
* @type {(input: string) => boolean} | ||
*/ | ||
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) { | ||
@@ -230,5 +179,3 @@ if (typeof input !== 'string' || !input.length) return false; | ||
/** | ||
* @type {(value: string) => boolean} | ||
*/ | ||
/** @type {(value: string) => boolean} */ | ||
export function isValidPattern(value) { | ||
@@ -238,5 +185,3 @@ return typeof value === 'string' && value.length > 0 && !/[\\\/\:]/.test(value); | ||
/** | ||
* @type {(num: number) => boolean} | ||
*/ | ||
/** @type {(num: number) => boolean} */ | ||
export function isValidPort(num) { | ||
@@ -247,8 +192,9 @@ return Number.isSafeInteger(num) && num >= 1 && num <= 65_535; | ||
/** | ||
* @param {{ root: string } & Partial<ServerOptions>} options | ||
* @param {{ error: ErrorList }} [context] | ||
* @returns {ServerOptions} | ||
*/ | ||
@param {{ root: string } & Partial<ServerOptions>} options | ||
@param {{ error: ErrorList }} [context] | ||
@returns {ServerOptions} | ||
*/ | ||
export function serverOptions(options, context) { | ||
const validator = new OptionsValidator(context?.error); | ||
/** @type {Partial<ServerOptions>} */ | ||
@@ -271,3 +217,2 @@ const checked = { | ||
}; | ||
for (const [key, value] of Object.entries(checked)) { | ||
@@ -274,0 +219,0 @@ // @ts-ignore |
@@ -10,16 +10,8 @@ import { basename, dirname } from 'node:path'; | ||
@typedef {import('./types.d.ts').ServerOptions} ServerOptions | ||
**/ | ||
*/ | ||
const html = (s = '') => escapeHtml(s, 'text'); | ||
const attr = (s = '') => escapeHtml(s, 'attr'); | ||
/** | ||
* @type {Map<string, string>} | ||
*/ | ||
/** @type {Map<string, string>} */ | ||
const assetCache = new Map(); | ||
/** | ||
* @param {string} localPath | ||
* @returns {Promise<string>} | ||
*/ | ||
/** @type {(localPath: string) => Promise<string>} */ | ||
export async function readAsset(localPath) { | ||
@@ -33,10 +25,10 @@ if (!assetCache.has(localPath)) { | ||
/** | ||
* @param {{ base?: string; body: string; icon?: 'list' | 'error'; title?: string }} data | ||
* @returns {Promise<string>} | ||
*/ | ||
@typedef {{ base?: string; body: string; icon?: 'list' | 'error'; title?: string }} HtmlTemplateData | ||
@type {(data: HtmlTemplateData) => Promise<string>} | ||
*/ | ||
async function htmlTemplate({ base, body, icon, title }) { | ||
const [css, svgSprite, svgIcon] = await Promise.all([ | ||
readAsset('lib/assets/styles.css'), | ||
readAsset('lib/assets/icons.svg'), | ||
icon === 'list' || icon === 'error' ? readAsset(`lib/assets/favicon-${icon}.svg`) : undefined, | ||
readAsset('assets/styles.css'), | ||
readAsset('assets/icons.svg'), | ||
icon === 'list' || icon === 'error' ? readAsset(`assets/favicon-${icon}.svg`) : undefined, | ||
]); | ||
@@ -63,5 +55,5 @@ | ||
/** | ||
* @param {{ status: number, urlPath: string }} data | ||
* @returns {Promise<string>} | ||
*/ | ||
@param {{ status: number, urlPath: string }} data | ||
@returns {Promise<string>} | ||
*/ | ||
export function errorPage({ status, urlPath }) { | ||
@@ -91,6 +83,6 @@ const displayPath = decodeURIPathSegments(urlPath); | ||
/** | ||
* @param {{ urlPath: string; file: ResolvedFile; items: DirIndexItem[] }} data | ||
* @param {Pick<ServerOptions, 'root' | 'ext'>} options | ||
* @returns {Promise<string>} | ||
*/ | ||
@param {{ urlPath: string; file: ResolvedFile; items: DirIndexItem[] }} data | ||
@param {Pick<ServerOptions, 'root' | 'ext'>} options | ||
@returns {Promise<string>} | ||
*/ | ||
export function dirListPage({ urlPath, file, items }, options) { | ||
@@ -133,6 +125,4 @@ const rootName = basename(options.root); | ||
/** | ||
* @param {DirIndexItem} item | ||
* @param {Pick<ServerOptions, 'ext'>} options | ||
* @returns {string} | ||
*/ | ||
@type {(item: DirIndexItem, options: Pick<ServerOptions, 'ext'>) => string} | ||
*/ | ||
function dirListItem(item, { ext }) { | ||
@@ -171,3 +161,4 @@ const { filePath, isParent = false } = item; | ||
function htmlBreadcrumbs(path = '') { | ||
/** @type {(path: string) => string} */ | ||
function htmlBreadcrumbs(path) { | ||
const slash = '<span class="bc-sep">/</span>'; | ||
@@ -188,6 +179,3 @@ return path | ||
/** | ||
* @param {DirIndexItem} item | ||
* @returns {boolean} | ||
*/ | ||
/** @type {(item: DirIndexItem) => boolean} */ | ||
function isDirLike(item) { | ||
@@ -197,12 +185,25 @@ return item.kind === 'dir' || (item.kind === 'link' && item.target?.kind === 'dir'); | ||
function decodeURIPathSegment(s = '') { | ||
/** @type {(s: string) => string} */ | ||
function decodeURIPathSegment(s) { | ||
return decodeURIComponent(s).replaceAll('\\', '\\\\').replaceAll('/', '\\/'); | ||
} | ||
function decodeURIPathSegments(path = '') { | ||
/** @type {(path: string) => string} */ | ||
function decodeURIPathSegments(path) { | ||
return path.split('/').map(decodeURIPathSegment).join('/'); | ||
} | ||
function nl2sp(input = '') { | ||
/** @type {(input: string) => string} */ | ||
function attr(str) { | ||
return escapeHtml(str, 'attr'); | ||
} | ||
/** @type {(input: string) => string} */ | ||
function html(str) { | ||
return escapeHtml(str, 'text'); | ||
} | ||
/** @type {(input: string) => string} */ | ||
function nl2sp(input) { | ||
return input.replace(/[\u{000A}-\u{000D}\u{2028}]/gu, ' '); | ||
} |
import { fwdSlash } from './utils.js'; | ||
/** | ||
* @typedef {{ caseSensitive: boolean }} PathMatcherOptions | ||
*/ | ||
export class PathMatcher { | ||
@@ -13,14 +10,12 @@ /** @type {Array<string | RegExp>} */ | ||
/** @type {PathMatcherOptions} */ | ||
#options = { | ||
caseSensitive: true, | ||
}; | ||
/** @type {boolean} */ | ||
#caseSensitive = true; | ||
/** | ||
* @param {string[]} patterns | ||
* @param {Partial<PathMatcherOptions>} [options] | ||
*/ | ||
constructor(patterns, { caseSensitive } = {}) { | ||
if (typeof caseSensitive === 'boolean') { | ||
this.#options.caseSensitive = caseSensitive; | ||
@param {string[]} patterns | ||
@param {Partial<{ caseSensitive: boolean }>} [options] | ||
*/ | ||
constructor(patterns, options) { | ||
if (typeof options?.caseSensitive === 'boolean') { | ||
this.#caseSensitive = options.caseSensitive; | ||
} | ||
@@ -38,6 +33,3 @@ for (const input of patterns) { | ||
/** | ||
* @param {string} filePath | ||
* @returns {boolean} | ||
*/ | ||
/** @type {(filePath: string) => boolean} */ | ||
test(filePath) { | ||
@@ -52,12 +44,5 @@ if (this.#positive.length === 0) { | ||
get rules() { | ||
return structuredClone({ positive: this.#positive, negative: this.#negative }); | ||
} | ||
/** | ||
* @param {string} input | ||
* @returns {string | RegExp | null} | ||
*/ | ||
/** @type {(input: string) => string | RegExp | null} */ | ||
#parse(input) { | ||
if (this.#options.caseSensitive === false) { | ||
if (this.#caseSensitive === false) { | ||
input = input.toLowerCase(); | ||
@@ -75,9 +60,5 @@ } | ||
/** | ||
* @param {string | RegExp} pattern | ||
* @param {string} value | ||
* @returns {boolean} | ||
*/ | ||
/** @type {(pattern: string | RegExp, value: string) => boolean} */ | ||
#matchPattern(pattern, value) { | ||
if (this.#options.caseSensitive === false) { | ||
if (this.#caseSensitive === false) { | ||
value = value.toLowerCase(); | ||
@@ -94,6 +75,3 @@ } | ||
/** | ||
* @param {string[]} segments | ||
* @returns {string[]} | ||
*/ | ||
/** @type {(segments: string[]) => string[]} */ | ||
#matchSegments(segments) { | ||
@@ -107,2 +85,6 @@ return segments.filter((segment) => { | ||
} | ||
data() { | ||
return { positive: this.#positive, negative: this.#negative }; | ||
} | ||
} |
@@ -12,3 +12,3 @@ import { join, sep as dirSep } from 'node:path'; | ||
@typedef {import('./types.d.ts').ServerOptions} ServerOptions | ||
**/ | ||
*/ | ||
@@ -31,20 +31,15 @@ export class FileResolver { | ||
/** | ||
* @param {{root: string } & Partial<ServerOptions>} options | ||
*/ | ||
constructor({ root, ext, dirFile, dirList, exclude }) { | ||
if (typeof root !== 'string') { | ||
/** @param {{root: string } & Partial<ServerOptions>} options */ | ||
constructor(options) { | ||
if (typeof options.root !== 'string') { | ||
throw new Error('Missing root directory'); | ||
} | ||
this.#root = trimSlash(root, { end: true }); | ||
if (Array.isArray(ext)) this.#ext = ext; | ||
if (Array.isArray(dirFile)) this.#dirFile = dirFile; | ||
if (typeof dirList === 'boolean') this.#dirList = dirList; | ||
this.#excludeMatcher = new PathMatcher(exclude ?? [], { caseSensitive: true }); | ||
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 }); | ||
} | ||
/** | ||
* @param {string} url | ||
* @returns {Promise<ResolveResult>} | ||
*/ | ||
/** @type {(url: string) => Promise<ResolveResult>} */ | ||
async find(url) { | ||
@@ -99,6 +94,3 @@ const urlPath = this.cleanUrlPath(url); | ||
/** | ||
* @param {string} dirPath | ||
* @returns {Promise<DirIndexItem[]>} | ||
*/ | ||
/** @type {(dirPath: string) => Promise<DirIndexItem[]>} */ | ||
async index(dirPath) { | ||
@@ -139,7 +131,6 @@ if (!this.#dirList) return []; | ||
/** | ||
* Locate alternative files that can be served for a resource, | ||
* using the config for extensions and index file lookup. | ||
* @param {string} fullPath | ||
* @returns {Promise<FSEntryBase>} | ||
*/ | ||
Locate alternative files that can be served for a resource, | ||
using the config for extensions and index file lookup. | ||
@type {(fullPath: string) => Promise<FSEntryBase>} | ||
*/ | ||
async locateFile(fullPath) { | ||
@@ -170,6 +161,3 @@ const targetKind = await getKind(fullPath); | ||
/** | ||
* @param {string | null} localPath | ||
* @returns {boolean} | ||
*/ | ||
/** @type {(localPath: string | null) => boolean} */ | ||
allowedLocalPath(localPath) { | ||
@@ -182,10 +170,17 @@ if (typeof localPath === 'string') { | ||
/** | ||
* @param {string} url | ||
* @returns {string | null} | ||
*/ | ||
/** @type {(urlPath: string) => boolean} */ | ||
allowedUrlPath(urlPath) { | ||
const forbidden = ['/', '\\', '..']; | ||
const segments = urlPath | ||
.split('/') | ||
.filter(Boolean) | ||
.map((s) => decodeURIComponent(s)); | ||
return segments.every((s) => forbidden.every((f) => !s.includes(f))); | ||
} | ||
/** @type {(url: string) => string | null} */ | ||
cleanUrlPath(url) { | ||
try { | ||
const path = fwdSlash(new URL(url, 'http://localhost/').pathname); | ||
if (this.validateUrlPath(path)) { | ||
if (this.allowedUrlPath(path)) { | ||
return path.startsWith('/') ? path : `/${path}`; | ||
@@ -197,6 +192,3 @@ } | ||
/** | ||
* @param {string} fullPath | ||
* @returns {string | null} | ||
*/ | ||
/** @type {(fullPath: string) => string | null} */ | ||
localPath(fullPath) { | ||
@@ -209,6 +201,3 @@ if (this.withinRoot(fullPath)) { | ||
/** | ||
* @param {string | null} urlPath | ||
* @returns {string | null} | ||
*/ | ||
/** @type {(urlPath: string | null) => string | null} */ | ||
urlToTargetPath(urlPath) { | ||
@@ -222,18 +211,3 @@ if (urlPath && urlPath.startsWith('/')) { | ||
/** | ||
* @param {string} urlPath | ||
*/ | ||
validateUrlPath(urlPath) { | ||
const forbidden = ['/', '\\', '..']; | ||
const segments = urlPath | ||
.split('/') | ||
.filter(Boolean) | ||
.map((s) => decodeURIComponent(s)); | ||
return segments.every((s) => forbidden.every((f) => !s.includes(f))); | ||
} | ||
/** | ||
* @param {string} fullPath | ||
* @returns {boolean} | ||
*/ | ||
/** @type {(fullPath: string) => boolean} */ | ||
withinRoot(fullPath) { | ||
@@ -240,0 +214,0 @@ if (fullPath.includes('..')) return false; |
import { Buffer } from 'node:buffer'; | ||
import { createReadStream } from 'node:fs'; | ||
import { open } from 'node:fs/promises'; | ||
import { open, stat } from 'node:fs/promises'; | ||
import { createServer } from 'node:http'; | ||
@@ -17,5 +17,3 @@ import { createGzip, gzipSync } from 'node:zlib'; | ||
@typedef {import('node:http').IncomingMessage} IncomingMessage | ||
@typedef {import('node:http').Server} Server | ||
@typedef {import('node:http').ServerResponse} ServerResponse | ||
@typedef {import('./content-type.js').TypeResult} TypeResult | ||
@@ -31,9 +29,9 @@ @typedef {import('./types.d.ts').ReqResMeta} ReqResMeta | ||
}} SendPayload | ||
**/ | ||
*/ | ||
/** | ||
* @param {ServerOptions} options | ||
* @param {{ logNetwork?: (data: ReqResMeta) => void }} [callbacks] | ||
* @returns {Server} | ||
*/ | ||
@param {ServerOptions} options | ||
@param {{ logNetwork?: (data: ReqResMeta) => void }} [callbacks] | ||
@returns {import('node:http').Server} | ||
*/ | ||
export function staticServer(options, { logNetwork } = {}) { | ||
@@ -66,17 +64,17 @@ const resolver = new FileResolver(options); | ||
/** | ||
* File matching the requested urlPath, if found and readable | ||
* @type {ResolvedFile | null} | ||
*/ | ||
File matching the requested urlPath, if found and readable | ||
@type {ResolvedFile | null} | ||
*/ | ||
file = null; | ||
/** | ||
* Error that may be logged to the terminal | ||
* @type {Error | string | undefined} | ||
*/ | ||
Error that may be logged to the terminal | ||
@type {Error | string | undefined} | ||
*/ | ||
error; | ||
/** | ||
* @param {{ req: IncomingMessage, res: ServerResponse }} reqRes | ||
* @param {FileResolver} resolver | ||
* @param {ServerOptions & {_noStream?: boolean}} options | ||
*/ | ||
@param {{ req: IncomingMessage, res: ServerResponse }} reqRes | ||
@param {FileResolver} resolver | ||
@param {ServerOptions & {_noStream?: boolean}} options | ||
*/ | ||
constructor({ req, res }, resolver, options) { | ||
@@ -146,5 +144,3 @@ this.#req = req; | ||
/** | ||
* @param {ResolvedFile} file | ||
*/ | ||
/** @type {(file: ResolvedFile) => Promise<void>} */ | ||
async #sendFile(file) { | ||
@@ -162,11 +158,7 @@ /** @type {FileHandle | undefined} */ | ||
handle = await open(file.filePath); | ||
statSize = (await handle.stat()).size; | ||
contentType = await getContentType({ filePath: file.filePath, fileHandle: handle }); | ||
statSize = (await stat(file.filePath)).size; | ||
contentType = await getContentType({ path: file.filePath, handle }); | ||
} catch (/** @type {any} */ err) { | ||
if (err?.syscall === 'open' && err.code === 'EBUSY') { | ||
this.status = err?.syscall === 'open' && err.code === 'EBUSY' ? 403 : 500; | ||
} | ||
if (err?.message) { | ||
this.error = err; | ||
} | ||
this.status = err?.code === 'EBUSY' ? 403 : 500; | ||
if (err && (err.message || typeof err === 'object')) this.error = err; | ||
} finally { | ||
@@ -200,5 +192,3 @@ await handle?.close(); | ||
/** | ||
* @param {ResolvedFile} dir | ||
*/ | ||
/** @type {(dir: ResolvedFile) => Promise<void>} */ | ||
async #sendListPage(dir) { | ||
@@ -222,2 +212,3 @@ this.#setHeaders('index.html', { | ||
/** @type {() => Promise<void>} */ | ||
async #sendErrorPage() { | ||
@@ -239,5 +230,3 @@ this.#setHeaders('error.html', { | ||
/** | ||
* @param {SendPayload} [payload] | ||
*/ | ||
/** @type {(payload?: SendPayload) => void} */ | ||
#send({ body, isText = false, statSize } = {}) { | ||
@@ -294,6 +283,4 @@ // stop early if possible | ||
/** | ||
* @param {string} name | ||
* @param {null | number | string | string[]} value | ||
* @param {boolean} [normalizeCase] | ||
*/ | ||
@type {(name: string, value: null | number | string | string[], normalizeCase?: boolean) => void} | ||
*/ | ||
#header(name, value, normalizeCase = true) { | ||
@@ -311,6 +298,5 @@ if (this.#res.headersSent) return; | ||
/** | ||
* Set all response headers, except for content-length | ||
* @param {string} localPath | ||
* @param {Partial<{ contentType: string, cors: boolean; headers: ServerOptions['headers'] }>} options | ||
*/ | ||
Set all response headers, except for content-length | ||
@type {(localPath: string, options: Partial<{ contentType: string, cors: boolean; headers: ServerOptions['headers'] }>) => void} | ||
*/ | ||
#setHeaders(localPath, { contentType, cors, headers }) { | ||
@@ -337,6 +323,4 @@ if (this.#res.headersSent) return; | ||
const blockList = ['content-encoding', 'content-length']; | ||
for (const { name, value } of fileHeaders(localPath, headerRules)) { | ||
if (!blockList.includes(name.toLowerCase())) { | ||
this.#header(name, value, false); | ||
} | ||
for (const { name, value } of fileHeaders(localPath, headerRules, blockList)) { | ||
this.#header(name, value, false); | ||
} | ||
@@ -360,3 +344,3 @@ } | ||
/** @returns {ReqResMeta} */ | ||
/** @type {() => ReqResMeta} */ | ||
data() { | ||
@@ -369,5 +353,4 @@ const { startedAt, endedAt, status, method, url, urlPath, file, error } = this; | ||
/** | ||
* @param {{ accept?: string | string[]; isText?: boolean; statSize?: number }} data | ||
* @returns {boolean} | ||
*/ | ||
@type {(data: { accept?: string | string[]; isText?: boolean; statSize?: number }) => boolean} | ||
*/ | ||
function canCompress({ accept = '', statSize = 0, isText = false }) { | ||
@@ -385,9 +368,7 @@ accept = Array.isArray(accept) ? accept.join(',') : accept; | ||
/** | ||
* @param {string} localPath | ||
* @param {ServerOptions['headers']} rules | ||
* @param {string[]} [blockList] | ||
*/ | ||
@type {(localPath: string, rules: ServerOptions['headers'], blockList?: string[]) => Array<{name: string; value: string}>} | ||
*/ | ||
export function fileHeaders(localPath, rules, blockList = []) { | ||
/** @type {Array<{ name: string, value: string }>} */ | ||
const headers = []; | ||
/** @type {ReturnType<fileHeaders>} */ | ||
const result = []; | ||
for (const rule of rules) { | ||
@@ -399,12 +380,10 @@ if (Array.isArray(rule.include)) { | ||
for (const [name, value] of Object.entries(rule.headers)) { | ||
if (blockList.length && blockList.includes(name.toLowerCase())) continue; | ||
headers.push({ name, value: String(value) }); | ||
if (blockList.includes(name.toLowerCase())) continue; | ||
result.push({ name, value: String(value) }); | ||
} | ||
} | ||
return headers; | ||
return result; | ||
} | ||
/** | ||
* @param {Pick<IncomingMessage, 'method' | 'headers'>} req | ||
*/ | ||
/** @type {(req: Pick<IncomingMessage, 'method' | 'headers'>) => boolean} */ | ||
function isPreflight({ method, headers }) { | ||
@@ -418,6 +397,3 @@ return ( | ||
/** | ||
* @param {string} [input] | ||
* @returns {string[]} | ||
*/ | ||
/** @type {(input?: string) => string[]} */ | ||
function parseHeaderNames(input = '') { | ||
@@ -424,0 +400,0 @@ const isHeader = (h = '') => /^[A-Za-z\d-_]+$/.test(h); |
@@ -23,3 +23,10 @@ export type DirIndexItem = ResolvedFile & { | ||
export type OptionName = | ||
export interface OptionSpec { | ||
help: string; | ||
names: string[]; | ||
negate?: string; | ||
default?: string | string[]; | ||
} | ||
export type OptionSpecs = Record< | ||
| 'cors' | ||
@@ -35,11 +42,6 @@ | 'dirFile' | ||
| 'port' | ||
| 'version'; | ||
| 'version', | ||
OptionSpec | ||
>; | ||
export interface OptionSpec { | ||
help: string; | ||
names: string[]; | ||
negate?: string; | ||
default?: string | string[]; | ||
} | ||
export interface PortsConfig { | ||
@@ -46,0 +48,0 @@ initial: number; |
@@ -5,7 +5,5 @@ import { env, versions } from 'node:process'; | ||
@typedef {import('./types.d.ts').ErrorList} ErrorList | ||
**/ | ||
*/ | ||
/** | ||
* @type {(value: number, min: number, max: number) => number} | ||
*/ | ||
/** @type {(value: number, min: number, max: number) => number} */ | ||
export function clamp(value, min, max) { | ||
@@ -16,5 +14,3 @@ if (typeof value !== 'number') value = min; | ||
/** | ||
* @type {(input: string, context?: 'text' | 'attr') => string} | ||
*/ | ||
/** @type {(input: string, context?: 'text' | 'attr') => string} */ | ||
export function escapeHtml(input, context = 'text') { | ||
@@ -27,5 +23,3 @@ if (typeof input !== 'string') return ''; | ||
/** | ||
* @returns {ErrorList} | ||
*/ | ||
/** @type {() => ErrorList} */ | ||
export function errorList() { | ||
@@ -39,5 +33,3 @@ /** @type {string[]} */ | ||
/** | ||
* @type {(input: string) => string} | ||
*/ | ||
/** @type {(input: string) => string} */ | ||
export function fwdSlash(input = '') { | ||
@@ -47,5 +39,3 @@ return input.replace(/\\/g, '/').replace(/\/{2,}/g, '/'); | ||
/** | ||
* @type {(key: string) => string} | ||
*/ | ||
/** @type {(key: string) => string} */ | ||
export function getEnv(key) { | ||
@@ -55,5 +45,3 @@ return env[key] ?? ''; | ||
/** | ||
* @returns {'bun' | 'deno' | 'node' | 'webcontainer'} | ||
*/ | ||
/** @type {() => 'bun' | 'deno' | 'node' | 'webcontainer'} */ | ||
export const getRuntime = once(() => { | ||
@@ -66,6 +54,3 @@ if (versions.bun && globalThis.Bun) return 'bun'; | ||
/** | ||
* @param {string} name | ||
* @returns {string} | ||
*/ | ||
/** @type {(name: string) => string} */ | ||
export function headerCase(name) { | ||
@@ -75,5 +60,3 @@ return name.replace(/((^|\b|_)[a-z])/g, (s) => s.toUpperCase()); | ||
/** | ||
* @type {(address: string) => boolean} | ||
*/ | ||
/** @type {(address: string) => boolean} */ | ||
export function isPrivateIPv4(address = '') { | ||
@@ -95,5 +78,3 @@ const bytes = address.split('.').map(Number); | ||
/** | ||
* @type {(start: number, end: number, limit?: number) => number[]} | ||
*/ | ||
/** @type {(start: number, end: number, limit?: number) => number[]} */ | ||
export function intRange(start, end, limit = 1_000) { | ||
@@ -111,8 +92,7 @@ for (const [key, val] of Object.entries({ start, end, limit })) { | ||
/** | ||
* Cache a function's result after the first call | ||
* @template Result | ||
* @param {() => Result} fn | ||
*/ | ||
Cache a function's result after the first call | ||
@type {<Result>(fn: () => Result) => () => Result} | ||
*/ | ||
export function once(fn) { | ||
/** @type {Result | undefined} */ | ||
/** @type {ReturnType<fn>} */ | ||
let value; | ||
@@ -126,4 +106,4 @@ return () => { | ||
/** | ||
* @type {(input: string, options?: { start?: boolean; end?: boolean }) => string} | ||
*/ | ||
@type {(input: string, options?: { start?: boolean; end?: boolean }) => string} | ||
*/ | ||
export function trimSlash(input = '', { start, end } = { start: true, end: true }) { | ||
@@ -137,11 +117,7 @@ if (start === true) input = input.replace(/^[\/\\]/, ''); | ||
/** @type {{ resolve: (value?: any) => void; reject: (reason?: any) => void }} */ | ||
const resolvers = { | ||
resolve: () => {}, | ||
reject: () => {}, | ||
}; | ||
let resolvers = { resolve: () => {}, reject: () => {} }; | ||
const promise = new Promise((resolve, reject) => { | ||
resolvers.resolve = resolve; | ||
resolvers.reject = reject; | ||
resolvers = { resolve, reject }; | ||
}); | ||
return { promise, ...resolvers }; | ||
} |
{ | ||
"name": "servitsy", | ||
"version": "0.4.0", | ||
"version": "0.4.1", | ||
"license": "MIT", | ||
@@ -33,2 +33,3 @@ "description": "Small, local HTTP server for static files", | ||
"files": [ | ||
"./assets", | ||
"./bin", | ||
@@ -45,3 +46,3 @@ "./lib", | ||
"devDependencies": { | ||
"@types/node": "^20.16.13", | ||
"@types/node": "^20.17.1", | ||
"fs-fixture": "^2.5.0", | ||
@@ -48,0 +49,0 @@ "linkedom": "^0.18.5", |
@@ -5,3 +5,3 @@ # servitsy | ||
- **Small:** no dependencies, 26.5 kilobytes gzipped. | ||
- **Small:** no dependencies, 26 kilobytes gzipped. | ||
- **Local:** designed for local development workflows. | ||
@@ -68,3 +68,3 @@ - **Static:** serves files and directory listings. | ||
| ------------- | ------- | ------------ | --------------- | | ||
| [servitsy] | 0.4.0 | 0 | 128 kB | | ||
| [servitsy] | 0.4.1 | 0 | 124 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
131
82385
2447