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.2 to 0.4.3

lib/handler.js

9

lib/args.js

@@ -5,3 +5,2 @@ import { CLI_OPTIONS, PORTS_CONFIG } from './constants.js';

/**
@typedef {import('./types.d.ts').ErrorList} ErrorList
@typedef {import('./types.d.ts').HttpHeaderRule} HttpHeaderRule

@@ -120,8 +119,8 @@ @typedef {import('./types.d.ts').OptionSpec} OptionSpec

/** @type {(args: CLIArgs, context?: { error: ErrorList }) => Partial<ServerOptions>} */
export function parseArgs(args, context) {
/** @type {(args: CLIArgs, context: { onError(msg: string): void }) => Partial<ServerOptions>} */
export function parseArgs(args, { onError }) {
const invalid = (optName = '', input = '') => {
const value =
typeof input === 'string' ? `'${input.replaceAll(`'`, `\'`)}'` : JSON.stringify(input);
context?.error(`invalid ${optName} value: ${value}`);
onError(`invalid ${optName} value: ${value}`);
};

@@ -191,3 +190,3 @@

for (const name of unknownArgs(args)) {
context?.error(`unknown option '${name}'`);
onError(`unknown option '${name}'`);
}

@@ -194,0 +193,0 @@

@@ -0,1 +1,3 @@

import { createServer } from 'node:http';
import { createRequire } from 'node:module';
import { homedir, networkInterfaces } from 'node:os';

@@ -8,6 +10,7 @@ import { sep as dirSep } from 'node:path';

import { CLI_OPTIONS, HOSTS_LOCAL, HOSTS_WILDCARD } from './constants.js';
import { checkDirAccess, pkgFilePath, readPkgJson } from './fs-utils.js';
import { checkDirAccess, readPkgJson } from './fs-utils.js';
import { RequestHandler } from './handler.js';
import { color, logger, requestLogLine } from './logger.js';
import { serverOptions } from './options.js';
import { staticServer } from './server.js';
import { FileResolver } from './resolver.js';
import { clamp, errorList, getRuntime, isPrivateIPv4 } from './utils.js';

@@ -27,3 +30,3 @@

if (args.has('--version')) {
const pkg = await readPkgJson();
const pkg = readPkgJson();
logger.write('info', pkg.version);

@@ -38,15 +41,9 @@ process.exitCode = 0;

const error = errorList();
const userOptions = parseArgs(args, { error });
const options = serverOptions({ root: '', ...userOptions }, { error });
const onError = errorList();
const userOptions = parseArgs(args, { onError });
const options = serverOptions({ root: '', ...userOptions }, { onError });
await checkDirAccess(options.root, { onError });
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) {
logger.error(...error.list);
if (onError.list.length) {
logger.error(...onError.list);
logger.error(`Try 'servitsy --help' for more information.`);

@@ -77,2 +74,5 @@ process.exitCode = 1;

/** @type {FileResolver} */
#resolver;
/** @param {ServerOptions} options */

@@ -86,12 +86,18 @@ constructor(options) {

this.#server = staticServer(options, {
logNetwork: (info) => {
logger.write('request', requestLogLine(info));
},
const resolver = new FileResolver(options);
const server = createServer(async (req, res) => {
const handler = new RequestHandler({ req, res, resolver, options });
res.on('close', () => {
logger.write('request', requestLogLine(handler.data()));
});
await handler.process();
});
this.#server.on('error', (error) => this.#onServerError(error));
this.#server.on('listening', () => {
server.on('error', (error) => this.#onServerError(error));
server.on('listening', () => {
const info = this.headerInfo();
if (info) logger.write('header', info, { top: 1, bottom: 1 });
});
this.#resolver = resolver;
this.#server = server;
}

@@ -98,0 +104,0 @@

/** @type {string[]} */
export const HOSTS_LOCAL = ['localhost', '127.0.0.1', '::1'];
/** @type {{ v4: string; v6: string }} */
export const HOSTS_WILDCARD = { v4: '0.0.0.0', v6: '::' };
export const HOSTS_WILDCARD = {
v4: '0.0.0.0',
v6: '::',
};
/** @type {import('./types.d.ts').PortsConfig} */
export const PORTS_CONFIG = {

@@ -32,3 +33,6 @@ initial: 8080,

/** @type {import('./types.d.ts').OptionSpecs} */
/**
@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 = {

@@ -35,0 +39,0 @@ cors: {

@@ -5,3 +5,2 @@ import { basename, extname } from 'node:path';

@typedef {import('node:fs/promises').FileHandle} FileHandle
@typedef {{

@@ -8,0 +7,0 @@ default: string;

@@ -1,14 +0,14 @@

import { access, constants, lstat, readdir, readFile, realpath, stat } from 'node:fs/promises';
import { isAbsolute, join, relative, sep as dirSep } from 'node:path';
import { fileURLToPath } from 'node:url';
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').FSEntryBase} FSEntryBase
@typedef {import('./types.d.ts').FSEntryKind} FSEntryKind
@typedef {import('./types.d.ts').ErrorList} ErrorList
@typedef {import('./types.d.ts').FSKind} FSKind
@typedef {import('./types.d.ts').FSLocation} FSLocation
*/
/** @type {(dirPath: string, context?: { error: ErrorList }) => Promise<boolean>} */
export async function checkDirAccess(dirPath, context) {
/** @type {(dirPath: string, context: { onError(msg: string): void }) => Promise<boolean>} */
export async function checkDirAccess(dirPath, { onError }) {
let msg = '';

@@ -33,7 +33,7 @@ try {

}
if (msg) context?.error(msg);
if (msg) onError(msg);
return false;
}
/** @type {(dirPath: string) => Promise<FSEntryBase[]>} */
/** @type {(dirPath: string) => Promise<FSLocation[]>} */
export async function getIndex(dirPath) {

@@ -51,3 +51,3 @@ try {

/** @type {(filePath: string) => Promise<FSEntryKind | null>} */
/** @type {(filePath: string) => Promise<FSKind>} */
export async function getKind(filePath) {

@@ -80,3 +80,3 @@ try {

/** @type {(filePath: string, kind?: FSEntryKind | null) => Promise<boolean>} */
/** @type {(filePath: string, kind?: FSKind) => Promise<boolean>} */
export async function isReadable(filePath, kind) {

@@ -103,29 +103,13 @@ if (kind === undefined) {

/** @type {(moduleUrl: URL | string) => string} */
export function moduleDirname(moduleUrl) {
return fileURLToPath(new URL('.', moduleUrl));
/** @type {() => Record<string, any>} */
export function readPkgJson() {
return createRequire(import.meta.url)('../package.json');
}
/** @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) {
return readFile(pkgFilePath(localPath), { encoding: 'utf8' });
}
/** @type {() => Promise<Record<string, any>>} */
export async function readPkgJson() {
const raw = await readPkgFile('package.json');
return JSON.parse(raw);
}
/** @type {(stats: import('node:fs').Dirent | import('node:fs').StatsBase<any>) => FSEntryKind | null} */
/** @type {(stats: {isSymbolicLink?(): boolean; isDirectory?(): boolean; isFile?(): boolean}) => FSKind} */
export function statsKind(stats) {
if (stats.isSymbolicLink()) return 'link';
if (stats.isDirectory()) return 'dir';
else if (stats.isFile()) return 'file';
if (stats.isSymbolicLink?.()) return 'link';
if (stats.isDirectory?.()) return 'dir';
else if (stats.isFile?.()) return 'file';
return null;
}

@@ -9,3 +9,3 @@ import { release } from 'node:os';

/**
@typedef {import('./types.d.ts').ReqResMeta} ReqResMeta
@typedef {import('./types.d.ts').ResMetaData} ResMetaData
@typedef {{

@@ -119,9 +119,10 @@ group: 'header' | 'info' | 'request' | 'error';

/** @type {(data: ReqResMeta) => string} */
export function requestLogLine({ startedAt, endedAt, status, method, urlPath, localPath, error }) {
/** @type {(data: import('./types.d.ts').ResMetaData) => string} */
export function requestLogLine({ status, method, urlPath, localPath, timing, error }) {
const { start, close } = timing;
const { style: _, brackets } = color;
const isSuccess = status >= 200 && status < 300;
const timestamp = new Date(endedAt ?? startedAt).toTimeString().split(' ')[0]?.padStart(8);
const duration = endedAt && startedAt ? endedAt - startedAt : -1;
const timestamp = start ? new Date(start).toTimeString().split(' ')[0]?.padStart(8) : undefined;
const duration = start && close ? Math.ceil(close - start) : undefined;

@@ -139,3 +140,3 @@ let displayPath = _(urlPath, 'cyan');

const line = [
_(timestamp, 'dim'),
timestamp && _(timestamp, 'dim'),
_(`${status}`, isSuccess ? 'green' : 'red'),

@@ -145,5 +146,5 @@ _('—', 'dim'),

displayPath,
duration >= 0 ? _(`(${duration}ms)`, 'dim') : undefined,
duration && _(`(${duration}ms)`, 'dim'),
]
.filter(Boolean)
.filter((s) => typeof s === 'string' && s !== '')
.join(' ');

@@ -150,0 +151,0 @@

@@ -6,3 +6,2 @@ import { isAbsolute, resolve } from 'node:path';

/**
@typedef {import('./types.d.ts').ErrorList} ErrorList
@typedef {import('./types.d.ts').HttpHeaderRule} HttpHeaderRule

@@ -13,5 +12,5 @@ @typedef {import('./types.d.ts').ServerOptions} ServerOptions

export class OptionsValidator {
/** @param {ErrorList} [errorList] */
constructor(errorList) {
this.errorList = errorList;
/** @param {(msg: string) => void} [onError] */
constructor(onError) {
this.onError = onError;
}

@@ -39,3 +38,3 @@

#error(msg = '') {
this.errorList?.(msg);
this.onError?.(msg);
}

@@ -177,7 +176,7 @@

@param {{ root: string } & Partial<ServerOptions>} options
@param {{ error: ErrorList }} [context]
@param {{ onError(msg: string): void }} [context]
@returns {ServerOptions}
*/
export function serverOptions(options, context) {
const validator = new OptionsValidator(context?.error);
const validator = new OptionsValidator(context?.onError);

@@ -184,0 +183,0 @@ /** @type {Partial<ServerOptions>} */

import { basename, dirname } from 'node:path';
import { readPkgFile } from './fs-utils.js';
import { FAVICON_LIST, FAVICON_ERROR, ICONS, STYLES } from './page-assets.js';
import { clamp, escapeHtml, trimSlash } from './utils.js';
/**
@typedef {import('./types.d.ts').DirIndexItem} DirIndexItem
@typedef {import('./types.d.ts').FSLocation} FSLocation
@typedef {import('./types.d.ts').ServerOptions} ServerOptions
*/
/** @type {Map<string, string>} */
const assetCache = new Map();
/** @type {(localPath: string) => Promise<string>} */
export async function readAsset(localPath) {
if (!assetCache.has(localPath)) {
assetCache.set(localPath, await readPkgFile(localPath));
}
return assetCache.get(localPath) ?? '';
}
/**
@typedef {{ base?: string; body: string; icon?: 'list' | 'error'; title?: string }} HtmlTemplateData
@type {(data: HtmlTemplateData) => Promise<string>}
@param {{ base?: string; body: string; icon?: 'list' | 'error'; title?: string }} data
*/
async function htmlTemplate({ base, body, icon, title }) {
const [css, svgSprite, svgIcon] = await Promise.all([
readAsset('assets/styles.css'),
readAsset('assets/icons.svg'),
icon === 'list' || icon === 'error' ? readAsset(`assets/favicon-${icon}.svg`) : undefined,
]);
const svgIcon = { list: FAVICON_LIST, error: FAVICON_ERROR }[String(icon)];
return `<!doctype html>

@@ -41,6 +24,6 @@ <html lang="en">

${svgIcon ? `<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;base64,${btoa(svgIcon)}">` : ''}
<style>${css.toString()}</style>
<style>${STYLES}</style>
</head>
<body>
${svgSprite.toString()}
${ICONS}
${body}

@@ -80,3 +63,3 @@ </body>

/**
@param {{ urlPath: string; filePath: string; items: DirIndexItem[] }} data
@param {{ urlPath: string; filePath: string; items: FSLocation[] }} data
@param {Pick<ServerOptions, 'root' | 'ext'>} options

@@ -91,11 +74,8 @@ @returns {Promise<string>}

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))];
if (trimmedUrl !== '') {
sorted.unshift({
filePath: dirname(filePath),
kind: 'dir',
isParent: true,
});
if (showParent) {
sorted.unshift({ filePath: parentPath, kind: 'dir' });
}

@@ -112,6 +92,6 @@

<h1>
Index of <span class="bc">${htmlBreadcrumbs(displayPath)}</span>
Index of <span class="bc">${renderBreadcrumbs(displayPath)}</span>
</h1>
<ul class="files" style="--max-col-count:${maxCols}">
${sorted.map((item) => dirListItem(item, options)).join('\n')}
${sorted.map((item) => renderListItem(item, { ext: options.ext, parentPath })).join('\n')}
</ul>

@@ -123,39 +103,40 @@ `.trim(),

/**
@type {(item: DirIndexItem, options: Pick<ServerOptions, 'ext'>) => string}
@type {(item: FSLocation, options: { ext: ServerOptions['ext']; parentPath: string }) => string}
*/
function dirListItem(item, { ext }) {
const { filePath, isParent = false } = item;
const isSymlink = item.kind === 'link';
const displayKind = isDirLike(item) ? 'dir' : 'file';
function renderListItem(item, { ext, parentPath }) {
const isDir = isDirLike(item);
const isParent = isDir && item.filePath === parentPath;
const iconId = `icon-${displayKind}${isSymlink ? '-link' : ''}`;
const className = ['files-item', `files-item--${displayKind}`];
if (isParent) className.push('files-item--parent');
if (isSymlink) className.push('files-item--symlink');
let icon = isDir ? 'icon-dir' : 'icon-file';
if (item.kind === 'link') icon += '-link';
let name = basename(item.filePath);
let suffix = '';
let label = '';
let href = encodeURIComponent(name);
const label = isParent ? 'Parent directory' : '';
const name = isParent ? '..' : basename(filePath);
const suffix = displayKind === 'dir' ? '/' : '';
// clean url: remove extension if possible
let href = encodeURIComponent(name);
if (displayKind === 'file') {
const match = ext.find((e) => filePath.endsWith(e));
if (isParent) {
name = '..';
href = '..';
label = 'Parent directory';
}
if (isDir) {
suffix = '/';
} 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);
}
const parts = [
`<li class="${attr(className.join(' '))}">\n`,
return [
`<li class="files-item">\n`,
`<a class="files-link" href="${attr(href)}"${label && ` aria-label="${attr(label)}" title="${attr(label)}"`}>`,
`<svg class="files-icon" width="20" height="20"><use xlink:href="#${attr(iconId)}"></use></svg>`,
`<svg class="files-icon" width="20" height="20"><use xlink:href="#${attr(icon)}"></use></svg>`,
`<span class="files-name filepath">${html(nl2sp(name))}${suffix && `<span>${html(suffix)}</span>`}</span>`,
`</a>`,
`\n</li>`,
];
return parts.join('');
].join('');
}
/** @type {(path: string) => string} */
function htmlBreadcrumbs(path) {
function renderBreadcrumbs(path) {
const slash = '<span class="bc-sep">/</span>';

@@ -176,3 +157,3 @@ return path

/** @type {(item: DirIndexItem) => boolean} */
/** @type {(item: FSLocation) => boolean} */
function isDirLike(item) {

@@ -179,0 +160,0 @@ return item.kind === 'dir' || (item.kind === 'link' && item.target?.kind === 'dir');

@@ -8,5 +8,3 @@ import { isAbsolute, join } from 'node:path';

/**
@typedef {import('./types.d.ts').DirIndexItem} DirIndexItem
@typedef {import('./types.d.ts').FSEntryBase} FSEntryBase
@typedef {import('./types.d.ts').ResolveResult} ResolveResult
@typedef {import('./types.d.ts').FSLocation} FSLocation
@typedef {import('./types.d.ts').ServerOptions} ServerOptions

@@ -45,13 +43,8 @@ */

/** @type {(url: string) => Promise<ResolveResult>} */
/** @param {string} url */
async find(url) {
const { urlPath, filePath: targetPath } = resolveUrlPath(this.#root, url);
/** @type {ResolveResult} */
const result = {
urlPath,
status: 404,
filePath: null,
kind: null,
};
/** @type {{status: number; urlPath: string; file?: FSLocation}} */
const result = { status: 404, urlPath };

@@ -74,8 +67,5 @@ if (targetPath == null) {

if (file.kind === 'file' || file.kind === 'dir') {
Object.assign(result, file);
}
// Check permissions (directories are always a 404 if dirList is false)
if (file.kind === 'file' || (file.kind === 'dir' && this.#dirList)) {
const allowed = this.allowedPath(file.filePath);
result.file = file;
const allowed =
file.kind === 'dir' && !this.#dirList ? false : this.allowedPath(file.filePath);
const readable = allowed && (await isReadable(file.filePath, file.kind));

@@ -88,7 +78,7 @@ result.status = allowed ? (readable ? 200 : 403) : 404;

/** @type {(dirPath: string) => Promise<DirIndexItem[]>} */
/** @type {(dirPath: string) => Promise<FSLocation[]>} */
async index(dirPath) {
if (!this.#dirList) return [];
/** @type {DirIndexItem[]} */
/** @type {FSLocation[]} */
const items = (await getIndex(dirPath)).filter(

@@ -116,3 +106,3 @@ (item) => item.kind != null && this.allowedPath(item.filePath),

/**
@type {(filePath: string[]) => Promise<FSEntryBase | void>}
@type {(filePath: string[]) => Promise<FSLocation | void>}
*/

@@ -132,3 +122,3 @@ async locateAltFiles(filePaths) {

using the config for extensions and index file lookup.
@type {(filePath: string) => Promise<FSEntryBase>}
@type {(filePath: string) => Promise<FSLocation>}
*/

@@ -135,0 +125,0 @@ async locateFile(filePath) {

@@ -1,16 +0,7 @@

export type DirIndexItem = FSEntryBase & {
isParent?: boolean;
target?: FSEntryBase;
};
export type FSKind = 'dir' | 'file' | 'link' | null;
export interface ErrorList {
(msg: string): void;
list: string[];
}
export type FSEntryKind = 'dir' | 'file' | 'link';
export interface FSEntryBase {
export interface FSLocation {
filePath: string;
kind: FSEntryKind | null;
kind: FSKind;
target?: { filePath: string; kind: FSKind };
}

@@ -30,40 +21,10 @@

export type OptionSpecs = Record<
| 'cors'
| 'dirFile'
| 'dirList'
| 'exclude'
| 'ext'
| 'gzip'
| 'header'
| 'help'
| 'host'
| 'port'
| 'version',
OptionSpec
>;
export interface PortsConfig {
initial: number;
count: number;
maxCount: number;
}
export interface ResolveResult {
status: number;
urlPath: string;
filePath: string | null;
kind: FSEntryKind | null;
}
export type ReqResMeta = {
export interface ResMetaData {
method: string;
status: number;
url: string;
urlPath: string;
localPath: string | null;
startedAt: number;
endedAt?: number;
timing: { start: number; send?: number; close?: number };
error?: Error | string;
};
}

@@ -70,0 +31,0 @@ export interface HttpOptions {

import { env, versions } from 'node:process';
/**
@typedef {import('./types.d.ts').ErrorList} ErrorList
*/
/** @type {(value: number, min: number, max: number) => number} */

@@ -21,3 +17,3 @@ export function clamp(value, min, max) {

/** @type {() => ErrorList} */
/** @type {() => { (msg: string): void; list: string[] }} */
export function errorList() {

@@ -24,0 +20,0 @@ /** @type {string[]} */

{
"name": "servitsy",
"version": "0.4.2",
"version": "0.4.3",
"license": "MIT",

@@ -33,3 +33,2 @@ "description": "Small, local HTTP server for static files",

"files": [
"./assets",
"./bin",

@@ -41,2 +40,4 @@ "./lib",

"scripts": {
"prepack": "npm run build && npm run typecheck && npm test",
"build": "node scripts/bundle.js",
"format": "prettier --write '**/*.{js,css}' '**/*config*.json'",

@@ -43,0 +44,0 @@ "test": "node --test --test-reporter=spec",

@@ -24,4 +24,4 @@ # servitsy

# Running with Deno
deno run --allow-net --allow-read --allow-sys npm:servitsy
# Running with Deno (will prompt for read access)
deno run --allow-net --allow-sys npm:servitsy
```

@@ -68,3 +68,3 @@

| ------------- | ------- | ------------ | --------------- |
| [servitsy] | 0.4.1 | 0 | 124 kB |
| [servitsy] | 0.4.3 | 0 | 116 kB |
| [servor] | 4.0.2 | 0 | 144 kB |

@@ -77,3 +77,3 @@ | [sirv-cli] | 3.0.0 | 12 | 396 kB |

Otherwise, [servor], [sirv-cli] or [servitsy] might work for you.
Otherwise [servitsy], [sirv-cli] or [servor] might work for you.

@@ -80,0 +80,0 @@ _† Installed size is the uncompressed size of the package and its dependencies (as reported by `du` on macOS; exact size may depend on the OS and/or filesystem)._

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