Huge News!Announcing our $40M Series B led by Abstract Ventures.Learn More
Socket
Sign inDemoInstall
Socket

servitsy

Package Overview
Dependencies
Maintainers
0
Versions
16
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

servitsy - npm Package Compare versions

Comparing version 0.4.3 to 0.4.4

81

lib/args.js
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) => {

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';

180

lib/handler.js

@@ -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 |

SocketSocket SOC 2 Logo

Product

  • Package Alerts
  • Integrations
  • Docs
  • Pricing
  • FAQ
  • Roadmap
  • Changelog

Packages

npm

Stay in touch

Get open source security insights delivered straight into your inbox.


  • Terms
  • Privacy
  • Security

Made with ⚡️ by Socket Inc