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.0 to 0.4.1

assets/favicon-error.svg

85

lib/args.js

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

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