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

servitsy

Package Overview
Dependencies
Maintainers
1
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.1.3 to 0.2.0

lib/fs-proxy.js

2

lib/args.js

@@ -109,3 +109,3 @@ export class CLIArgs {

get data() {
data() {
return structuredClone({

@@ -112,0 +112,0 @@ map: this.#map,

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

import { logger, requestLogLine } from './logger.js';
import { readPkgJson } from './node-fs.js';
import { readPkgJson } from './fs-proxy.js';
import { serverOptions } from './options.js';

@@ -18,2 +18,3 @@ import { staticServer } from './server.js';

@typedef {import('./types.js').OptionSpec} OptionSpec
@typedef {import('./types.js').ListenOptions} ListenOptions
@typedef {import('./types.js').ServerOptions} ServerOptions

@@ -54,3 +55,3 @@ **/

export class CLIServer {
/** @type {ServerOptions} */
/** @type {ListenOptions & ServerOptions} */
#options;

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

/**
* @param {ServerOptions} options
* @param {ListenOptions & ServerOptions} options
*/

@@ -71,0 +72,0 @@ constructor(options) {

@@ -32,2 +32,5 @@ /**

/** @type {string[]} */
export const SUPPORTED_METHODS = ['GET', 'HEAD', 'OPTIONS', 'POST'];
/** @type {Record<OptionName, OptionSpec>} */

@@ -34,0 +37,0 @@ export const CLI_OPTIONS = Object.freeze({

@@ -8,2 +8,3 @@ import { stderr, stdout } from 'node:process';

@typedef {import('./types.js').ErrorMessage} ErrorMessage
@typedef {import('./types.js').ReqResMeta} ReqResMeta

@@ -99,6 +100,6 @@ @typedef {{

/**
* @param {import('./types.js').ReqResInfo} info
* @param {ReqResMeta} data
* @returns {string}
*/
export function requestLogLine({ startedAt, endedAt, status, method, urlPath, localPath, error }) {
export function requestLogLine({ startedAt, endedAt, status, method, urlPath, file, error }) {
const { brackets, style } = color;

@@ -110,7 +111,8 @@

let displayPath = style(urlPath, 'cyan');
if (isSuccess && localPath) {
if (isSuccess && file?.localPath) {
const basePath = urlPath.length > 1 ? trimSlash(urlPath, { end: true }) : urlPath;
const suffix = pathSuffix(basePath, `/${fwdSlash(localPath)}`);
const suffix = pathSuffix(basePath, `/${fwdSlash(file.localPath)}`);
if (suffix) {
displayPath = style(basePath, 'cyan') + brackets(suffix, 'dim,gray,dim');
if (urlPath.length > 1 && urlPath.endsWith('/')) displayPath += style('/', 'cyan');
}

@@ -117,0 +119,0 @@ }

@@ -20,2 +20,3 @@ import { accessSync, statSync, constants as fsConstants } from 'node:fs';

@typedef {import('./types.js').PortsConfig} PortsConfig
@typedef {import('./types.js').ListenOptions} ListenOptions
@typedef {import('./types.js').ServerOptions} ServerOptions

@@ -40,5 +41,5 @@ @typedef {import('./utils.js').ErrorsContext & { mode: 'arg' | 'option' }} ValidationContext

/**
* @param {Partial<ServerOptions>} options
* @param {Partial<ListenOptions & ServerOptions>} options
* @param {CLIArgs} [args]
* @returns {{errors: ErrorMessage[]; options: ServerOptions}}
* @returns {{errors: ErrorMessage[]; options: ListenOptions & ServerOptions}}
*/

@@ -45,0 +46,0 @@ export function serverOptions(options, args) {

import { readFile } from 'node:fs/promises';
import { basename, dirname, join } from 'node:path';
import { readPkgFile } from './fs-proxy.js';
import { clamp, escapeHtml, getDirname, trimSlash } from './utils.js';

@@ -21,15 +22,12 @@

/**
* @param {string} file
* @param {string} localPath
* @returns {Promise<string>}
*/
async function readAsset(file) {
const fullPath = join(getDirname(import.meta.url), file);
const cached = assetCache.get(fullPath);
if (cached) {
return cached;
} else {
const contents = await readFile(fullPath, { encoding: 'utf8' });
assetCache.set(fullPath, contents);
return contents;
export async function readAsset(localPath) {
if (!assetCache.has(localPath)) {
const result = await readPkgFile(localPath, 'utf8');
const text = typeof result === 'string' ? result : '';
assetCache.set(localPath, text);
}
return assetCache.get(localPath) ?? '';
}

@@ -43,5 +41,5 @@

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,
readAsset('lib/assets/styles.css'),
readAsset('lib/assets/icons.svg'),
icon === 'list' || icon === 'error' ? readAsset(`lib/assets/favicon-${icon}.svg`) : undefined,
]);

@@ -73,21 +71,21 @@

const displayPath = decodeURIPathSegments(urlPath);
const pathHtml = `<code class="filepath">${html(displayPath)}</code>`;
let title = 'Error';
let desc = 'Something went wrong';
if (status === 403) {
title = '403: Forbidden';
desc = `Could not access <code class="filepath">${html(displayPath)}</code>`;
} else if (status === 404) {
title = '404: Not found';
desc = `Could not find <code class="filepath">${html(displayPath)}</code>`;
} else if (status === 500) {
title = '500: Error';
desc = `Could not serve <code class="filepath">${html(displayPath)}</code>`;
const page = (title = '', desc = '') => {
const body = `<h1>${html(title)}</h1>\n<p>${desc}</p>\n`;
return htmlTemplate({ icon: 'error', title, body });
};
switch (status) {
case 403:
return page('403: Forbidden', `Could not access ${pathHtml}`);
case 404:
return page('404: Not found', `Could not find ${pathHtml}`);
case 405:
return page('405: Method not allowed');
case 500:
return page('500: Error', `Could not serve ${pathHtml}`);
default:
return page('Error', 'Something went wrong');
}
return htmlTemplate({
title,
icon: 'error',
body: `<h1>${html(title)}</h1>\n<p>${desc}</p>\n`,
});
}

@@ -94,0 +92,0 @@

@@ -7,5 +7,5 @@ import { fwdSlash, trimSlash } from './utils.js';

@typedef {import('./types.js').FSEntryKind} FSEntryKind
@typedef {import('./types.js').FSUtils} FSUtils
@typedef {import('./types.js').ResolveOptions} ResolveOptions
@typedef {import('./types.js').FSProxy} FSProxy
@typedef {import('./types.js').ResolveResult} ResolveResult
@typedef {import('./types.js').ServerOptions} ServerOptions
**/

@@ -29,10 +29,10 @@

/** @type {FSUtils} */
#fsUtils;
/** @type {FSProxy} */
#fs;
/**
* @param {{root: string } & Partial<ResolveOptions>} options
* @param {FSUtils} fsUtils
* @param {{root: string } & Partial<ServerOptions>} options
* @param {FSProxy} fsProxy
*/
constructor({ root, ext, dirFile, dirList, exclude }, fsUtils) {
constructor({ root, ext, dirFile, dirList, exclude }, fsProxy) {
if (typeof root !== 'string') {

@@ -46,6 +46,20 @@ throw new Error('Missing root directory');

this.#excludeMatcher = new PathMatcher(exclude ?? [], { caseSensitive: true });
this.#fsUtils = fsUtils;
this.#fs = fsProxy;
}
/**
* @param {string} filePath
*/
async open(filePath) {
return this.#fs.open(filePath);
}
/**
* @param {string} filePath
*/
async read(filePath) {
return this.#fs.readFile(filePath);
}
/**
* @param {string} url

@@ -74,4 +88,4 @@ * @returns {Promise<ResolveResult>}

if (isSymlink) {
const filePath = await this.#fsUtils.realpath(resource.filePath);
const kind = filePath ? await this.#fsUtils.kind(filePath) : null;
const filePath = await this.#fs.realpath(resource.filePath);
const kind = filePath ? await this.#fs.kind(filePath) : null;
if (filePath) {

@@ -94,3 +108,3 @@ resource = { filePath, kind };

if (enabled && this.allowedPath(localPath)) {
const readable = await this.#fsUtils.readable(resource.filePath, resource.kind);
const readable = await this.#fs.readable(resource.filePath, resource.kind);
result.status = readable ? 200 : 403;

@@ -112,3 +126,3 @@ } else if (isSymlink) {

async locateFile(targetPath) {
const targetKind = await this.#fsUtils.kind(targetPath);
const targetKind = await this.#fs.kind(targetPath);

@@ -122,3 +136,3 @@ if (targetKind === 'file' || targetKind === 'link') {

if (targetKind === 'dir' && this.#dirFile.length) {
candidates = this.#dirFile.map((name) => this.#fsUtils.join(targetPath, name));
candidates = this.#dirFile.map((name) => this.#fs.join(targetPath, name));
} else if (targetKind === null && this.#ext.length) {

@@ -129,3 +143,3 @@ candidates = this.#ext.map((ext) => targetPath + ext);

for (const filePath of candidates) {
const kind = await this.#fsUtils.kind(filePath);
const kind = await this.#fs.kind(filePath);
if (kind === 'file' || kind === 'link') {

@@ -171,3 +185,3 @@ return { kind, filePath };

for (const { kind, filePath } of await this.#fsUtils.index(dirPath)) {
for (const { kind, filePath } of await this.#fs.index(dirPath)) {
const localPath = this.localPath(filePath);

@@ -185,4 +199,4 @@ if (kind != null && this.allowedPath(localPath)) {

if (item.kind === 'link') {
const filePath = await this.#fsUtils.realpath(item.filePath);
const kind = filePath ? await this.#fsUtils.kind(filePath) : null;
const filePath = await this.#fs.realpath(item.filePath);
const kind = filePath ? await this.#fs.kind(filePath) : null;
if (filePath != null && kind != null) {

@@ -221,3 +235,3 @@ item.target = {

if (urlPath && urlPath.startsWith('/')) {
const filePath = this.#fsUtils.join(this.#root, decodeURIComponent(urlPath));
const filePath = this.#fs.join(this.#root, decodeURIComponent(urlPath));
return trimSlash(filePath, { end: true });

@@ -246,3 +260,3 @@ }

if (filePath.includes('..')) return false;
const prefix = this.#root + this.#fsUtils.dirSep;
const prefix = this.#root + this.#fs.dirSep;
return filePath === this.#root || filePath.startsWith(prefix);

@@ -249,0 +263,0 @@ }

@@ -1,16 +0,23 @@

import { open } from 'node:fs/promises';
import { Buffer } from 'node:buffer';
import { createServer } from 'node:http';
import { SUPPORTED_METHODS } from './constants.js';
import { getContentType, typeForFilePath } from './content-type.js';
import { fsUtils } from './node-fs.js';
import { fsProxy } from './fs-proxy.js';
import { dirListPage, errorPage } from './pages.js';
import { FileResolver, PathMatcher } from './resolver.js';
import { headerCase, strBytes } from './utils.js';
/**
@typedef {import('node:fs/promises').FileHandle} FileHandle
@typedef {import('node:http').IncomingMessage} IncomingMessage
@typedef {import('node:http').Server} Server
@typedef {import('node:http').ServerResponse} ServerResponse
@typedef {import('./types.js').DirIndexItem} DirIndexItem
@typedef {import('./types.js').FSEntryKind} FSEntryKind
@typedef {import('./types.js').ReqResInfo} ReqResInfo
@typedef {import('./types.js').ReqResMeta} ReqResMeta
@typedef {import('./types.js').ResolvedFile} ResolvedFile
@typedef {import('./types.js').ResolveResult} ResolveResult
@typedef {import('./types.js').ServerOptions} ServerOptions
@typedef {'error' | 'headers' | 'list' | 'file'} ResponseMode
**/

@@ -20,138 +27,286 @@

* @param {ServerOptions} options
* @param {{ logNetwork?: (info: ReqResInfo) => void }} callbacks
* @returns {import('node:http').Server}
* @param {{ logNetwork?: (data: ReqResMeta) => void }} [callbacks]
* @returns {Server}
*/
export function staticServer(options, { logNetwork }) {
const resolver = new FileResolver(options, fsUtils);
export function staticServer(options, callbacks) {
const resolver = new FileResolver(options, fsProxy);
const handlerOptions = { ...options, streaming: true };
return createServer(async (req, res) => {
/**
* @type {Pick<ReqResInfo, 'method' | 'startedAt' | 'error'>}
*/
const logInfo = {
method: req.method ?? '',
startedAt: Date.now(),
};
const handler = new RequestHandler({ req, res }, resolver, handlerOptions);
res.on('close', () => {
handler.endedAt = Date.now();
callbacks?.logNetwork?.(handler.data);
});
await handler.process();
});
}
const { file, ...result } = await resolver.find(req.url ?? '');
export class RequestHandler {
#req;
#res;
#options;
#resolver;
if (logNetwork) {
res.on('close', () => {
logNetwork({
status: result.status,
urlPath: result.urlPath,
localPath: file?.localPath ?? null,
...logInfo,
endedAt: Date.now(),
});
});
/** @type {number} */
startedAt = Date.now();
/** @type {number | undefined} */
endedAt;
/** @type {string} */
url = '';
/** @type {string} */
urlPath = '';
/**
* File matching the requested urlPath, if found and readable
* @type {ResolvedFile | null}
*/
file = null;
/** @type {ResponseMode} */
mode = 'error';
/**
* Error that may be logged to the terminal
* @type {Error | string | undefined}
*/
error;
/**
* @param {{ req: IncomingMessage, res: ServerResponse }} reqRes
* @param {FileResolver} resolver
* @param {ServerOptions & {streaming: boolean}} options
*/
constructor({ req, res }, resolver, options) {
this.#req = req;
this.#res = res;
this.#resolver = resolver;
this.#options = options;
this.status = 404;
if (req.url) {
this.url = req.url;
this.urlPath = req.url.split(/[\?\#]/)[0];
}
}
get method() {
return this.#req.method ?? '';
}
get status() {
return this.#res.statusCode;
}
set status(code) {
this.#res.statusCode = code;
}
get headers() {
return this.#res.getHeaders();
}
async process() {
// bail for unsupported http methods
if (!SUPPORTED_METHODS.includes(this.method)) {
this.error = new Error(`HTTP method ${this.method} is not supported`);
this.status = 405;
return this.#sendErrorPage();
}
// no need to look up files for the '*' OPTIONS request
if (this.method === 'OPTIONS' && this.url === '*') {
this.status = 204;
this.#setHeaders('*', { cors: this.#options.cors });
return this.#send();
}
const { status, urlPath, file } = await this.#resolver.find(this.url);
this.status = status;
this.urlPath = urlPath;
this.file = file;
// found a file to serve
if (
result.status === 200 &&
file?.kind === 'file' &&
file.filePath != null &&
file.localPath != null
) {
let fileHandle;
try {
// check that we can actually open the file
// (especially on windows where it might be busy)
fileHandle = await open(file.filePath);
const headers = fileHeaders({
localPath: file.localPath,
contentType: await getContentType({
filePath: file.localPath,
fileHandle,
}),
cors: options.cors,
headers: options.headers,
});
res.writeHead(result.status, headers);
const stream = fileHandle.createReadStream({
autoClose: true,
start: 0,
});
stream.pipe(res);
} catch (/** @type {any} */ err) {
result.status = 500;
if (err?.syscall === 'open') {
if (err.code === 'EBUSY') result.status = 403;
fileHandle?.close();
}
if (err?.message) {
logInfo.error = err;
}
await sendErrorPage(res, result, options);
}
if (status === 200 && file?.kind === 'file' && file.localPath != null) {
return this.#sendFile(file);
}
// found a directory that we can show a listing for
else if (
result.status === 200 &&
file?.kind === 'dir' &&
file.filePath != null &&
file.localPath != null
) {
const items = await resolver.index(file.filePath);
await sendDirListPage(res, { ...result, file, items }, options);
else if (status === 200 && file?.kind === 'dir' && file.localPath != null) {
return this.#sendListPage(file);
}
// show an error page
else {
await sendErrorPage(res, result, options);
return this.#sendErrorPage();
}
/**
* @param {ResolvedFile} file
*/
async #sendFile(file) {
const { method } = this;
/** @type {FileHandle | undefined} */
let handle;
/** @type {string | undefined} */
let contentType;
/** @type {number | undefined} */
let contentLength;
try {
// check that we can actually open the file
// (especially on windows where it might be busy)
handle = await this.#resolver.open(file.filePath);
contentType = await getContentType({ filePath: file.filePath, fileHandle: handle });
contentLength = (await handle.stat()).size;
} catch (/** @type {any} */ err) {
this.status = 500;
if (err?.syscall === 'open') {
if (err.code === 'EBUSY') this.status = 403;
handle?.close();
}
if (err?.message) {
this.error = err;
}
return this.#sendErrorPage();
}
});
}
/**
* @param {import('node:http').ServerResponse} res
* @param {{ status: number, urlPath: string; file: ResolvedFile; items: DirIndexItem[] }} data
* @param {ServerOptions} options
*/
async function sendDirListPage(res, { status, urlPath, file, items }, options) {
const headers = fileHeaders({
localPath: 'index.html',
// ignore user options for directory listings
cors: false,
headers: [],
});
const body = await dirListPage({ urlPath, file, items }, options);
res.writeHead(status, headers);
res.write(body);
res.end();
}
this.#setHeaders(file.localPath ?? file.filePath, {
contentType,
contentLength,
cors: this.#options.cors,
headers: this.#options.headers,
});
/**
* @param {import('node:http').ServerResponse} res
* @param {Pick<ResolveResult, 'status' | 'urlPath'>} result
* @param {ServerOptions} options
*/
async function sendErrorPage(res, result, options) {
const headers = fileHeaders({
localPath: 'error.html',
cors: options.cors,
// ignore custom headers for error pages
headers: [],
});
const body = await errorPage(result);
res.writeHead(result.status, headers);
res.write(body);
res.end();
if (method === 'OPTIONS') {
handle.close();
this.status = 204;
this.#send();
} else if (method === 'HEAD') {
handle.close();
this.#send();
} else if (this.#options.streaming === false) {
handle.close();
const buffer = await this.#resolver.read(file.filePath);
this.#send(buffer);
} else {
const stream = handle.createReadStream({ autoClose: true, start: 0 });
this.#send(stream);
}
}
/**
* @param {ResolvedFile} dir
*/
async #sendListPage(dir) {
const items = await this.#resolver.index(dir.filePath);
let body;
let contentLength;
if (this.method !== 'OPTIONS') {
body = await dirListPage({ urlPath: this.urlPath, file: dir, items }, this.#options);
contentLength = strBytes(body);
}
this.#setHeaders('index.html', {
contentLength,
cors: false,
headers: [],
});
return this.#send(body);
}
async #sendErrorPage() {
let body;
let contentLength;
if (this.method !== 'OPTIONS') {
body = await errorPage({ status: this.status, urlPath: this.urlPath });
contentLength = strBytes(body);
}
this.#setHeaders('error.html', {
contentLength,
cors: this.#options.cors,
headers: [],
});
this.#send(body);
}
/**
* @param {string | import('node:buffer').Buffer | import('node:fs').ReadStream} [contents]
*/
#send(contents) {
if (this.method === 'HEAD' || this.method === 'OPTIONS') {
this.#res.end();
} else {
if (typeof contents === 'string' || Buffer.isBuffer(contents)) {
this.#res.write(contents);
this.#res.end();
} else if (typeof contents?.pipe === 'function') {
contents.pipe(this.#res);
}
}
}
/**
* @param {string} localPath
* @param {Partial<{ contentType: string, contentLength: number; cors: boolean; headers: ServerOptions['headers'] }>} options
*/
#setHeaders(localPath, { contentLength, contentType, cors, headers }) {
const { method, status } = this;
const isOptions = method === 'OPTIONS';
if (isOptions || status === 405) {
this.#setHeader('allow', SUPPORTED_METHODS.join(', '));
}
if (!isOptions) {
contentType ??= typeForFilePath(localPath).toString();
this.#setHeader('content-type', contentType);
}
if (isOptions || status === 204) {
contentLength = 0;
}
if (typeof contentLength === 'number') {
this.#setHeader('content-length', String(contentLength));
}
if (cors ?? this.#options.cors) {
this.#setCorsHeaders();
}
const headerRules = headers ?? this.#options.headers;
if (headerRules.length) {
for (const { name, value } of fileHeaders(localPath, headerRules)) {
this.#res.setHeader(name, value);
}
}
}
#setCorsHeaders() {
const origin = this.#req.headers['origin'];
if (typeof origin === 'string') {
this.#setHeader('access-control-allow-origin', origin);
}
if (isPreflight(this.#req)) {
this.#setHeader('access-control-allow-methods', SUPPORTED_METHODS.join(', '));
const allowHeaders = parseHeaderNames(this.#req.headers['access-control-request-headers']);
if (allowHeaders.length) {
this.#setHeader('access-control-allow-headers', allowHeaders.join(', '));
}
this.#setHeader('access-control-max-age', '60');
}
}
/**
* @param {string} name
* @param {string} value
*/
#setHeader(name, value) {
this.#res.setHeader(headerCase(name), value);
}
/** @returns {ReqResMeta} */
get data() {
const { startedAt, endedAt, status, method, url, urlPath, file, error } = this;
return { startedAt, endedAt, status, method, url, urlPath, file, error };
}
}
/**
* @param {{ localPath: string; contentType?: string; cors: boolean; headers: ServerOptions['headers'] }} data
* @returns {Record<string, string>}
* @param {string} localPath
* @param {ServerOptions['headers']} rules
*/
export function fileHeaders({ localPath, contentType, cors, headers }) {
/** @type {Record<string, string>} */
const obj = {};
const add = (key = '', value = '') => (obj[key.trim().toLowerCase()] = value);
add('content-type', contentType || typeForFilePath(localPath).toString());
if (cors) {
add('access-control-allow-origin', '*');
}
for (const rule of headers) {
export function fileHeaders(localPath, rules) {
/** @type {Array<{name: string; value: string}>} */
const headers = [];
for (const rule of rules) {
if (Array.isArray(rule.include)) {

@@ -161,7 +316,30 @@ const matcher = new PathMatcher(rule.include);

}
for (const [key, value] of Object.entries(rule.headers)) {
add(key, value);
for (const [name, value] of Object.entries(rule.headers)) {
headers.push({ name, value });
}
}
return obj;
return headers;
}
/**
* @param {Pick<IncomingMessage, 'method' | 'headers'>} req
*/
function isPreflight({ method, headers }) {
return (
method === 'OPTIONS' &&
typeof headers['origin'] === 'string' &&
typeof headers['access-control-request-method'] === 'string'
);
}
/**
* @param {string} [input]
* @returns {string[]}
*/
function parseHeaderNames(input = '') {
const isHeader = (h = '') => /^[A-Za-z\d-_]+$/.test(h);
return input
.split(',')
.map((h) => h.trim())
.filter(isHeader);
}

@@ -20,11 +20,17 @@ /**

join(...paths: string[]): string;
relative(from: string, to: string): string;
index(dirPath: string): Promise<FSEntryBase[]>;
info(filePath: string): Promise<FSEntryBase & {readable: boolean}>;
kind(filePath: string): Promise<FSEntryKind | null>;
open(filePath: string): Promise<import('node:fs/promises').FileHandle>;
readable(filePath: string, kind?: FSEntryKind | null): Promise<boolean>;
readFile(filePath: string): Promise<import('node:buffer').Buffer | string>;
realpath(filePath: string): Promise<string | null>;
}} FSUtils
}} FSProxy
@typedef {{
include?: string[];
headers: Record<string, string>;
}} HttpHeaderRule
@typedef {{
root: string;

@@ -35,18 +41,11 @@ ext: string[];

exclude: string[];
}} ResolveOptions
cors: boolean;
headers: HttpHeaderRule[];
}} ServerOptions
@typedef {{
include?: string[];
headers: Record<string, string>;
}} HttpHeaderRule
@typedef {{
host: string;
ports: number[];
cors: boolean;
headers: HttpHeaderRule[];
}} HttpOptions
}} ListenOptions
@typedef {HttpOptions & ResolveOptions} ServerOptions
@typedef {{

@@ -67,11 +66,9 @@ kind: FSEntryKind;

@typedef {{
startedAt: number;
readonly startedAt: number;
endedAt?: number;
status: number;
method: string;
urlPath: string;
localPath: string | null;
readonly method: string;
readonly url: string;
error?: Error | string;
}} ReqResInfo
} & ResolveResult} ReqResMeta
**/

@@ -55,8 +55,7 @@ import { fileURLToPath } from 'node:url';

/**
* @type {(input: string, options?: { start?: boolean; end?: boolean }) => string}
* @param {string} name
* @returns {string}
*/
export function trimSlash(input = '', { start, end } = { start: true, end: true }) {
if (start === true) input = input.replace(/^[\/\\]/, '');
if (end === true) input = input.replace(/[\/\\]$/, '');
return input;
export function headerCase(name) {
return name.replace(/((^|\b|_)[a-z])/g, (s) => s.toUpperCase());
}

@@ -109,2 +108,19 @@

/**
* @param {string} input
* @returns {number}
*/
export function strBytes(input) {
return new TextEncoder().encode(input).byteLength;
}
/**
* @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(/[\/\\]$/, '');
return input;
}
export function withResolvers() {

@@ -111,0 +127,0 @@ /** @type {{ resolve: (value?: any) => void; reject: (reason?: any) => void }} */

{
"name": "servitsy",
"version": "0.1.3",
"version": "0.2.0",
"keywords": [

@@ -41,4 +41,5 @@ "cli",

"devDependencies": {
"@types/node": "^20.16.5",
"linkedom": "^0.18.4",
"@types/node": "^20.16.6",
"linkedom": "^0.18.5",
"memfs": "^4.12.0",
"prettier": "^3.3.3",

@@ -45,0 +46,0 @@ "typescript": "~5.6.2"

@@ -5,3 +5,3 @@ # servitsy

- **Small:** no dependencies, 25 kilobytes gzipped.
- **Small:** no dependencies, 27 kilobytes gzipped.
- **Static:** serves static files and directory listings.

@@ -169,3 +169,3 @@ - **Local:** designed for single-user local workflows, not for production.

| ------------- | ------- | ------------ | ------------- |
| [servitsy] | 0.1.3 | 0 | 124 kB |
| [servitsy] | 0.2.0 | 0 | 128 kB |
| [servor] | 4.0.2 | 0 | 144 kB |

@@ -172,0 +172,0 @@ | [sirv-cli] | 2.0.2 | 12 | 392 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