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.1 to 0.4.2

28

lib/fs-utils.js
import { access, constants, lstat, readdir, readFile, realpath, stat } from 'node:fs/promises';
import { join } from 'node:path';
import { isAbsolute, join, relative, sep as dirSep } from 'node:path';
import { fileURLToPath } from 'node:url';
import { trimSlash } from './utils.js';

@@ -36,7 +37,2 @@ /**

/** @type {(moduleUrl: URL | string) => string} */
export function moduleDirname(moduleUrl) {
return fileURLToPath(new URL('.', moduleUrl));
}
/** @type {(dirPath: string) => Promise<FSEntryBase[]>} */

@@ -65,2 +61,10 @@ export async function getIndex(dirPath) {

/** @type {(root: string, filePath: string) => string | null} */
export function getLocalPath(root, filePath) {
if (isSubpath(root, filePath)) {
return trimSlash(filePath.slice(root.length), { start: true, end: true });
}
return null;
}
/** @type {(filePath: string) => Promise<string | null>} */

@@ -91,2 +95,14 @@ export async function getRealpath(filePath) {

/** @type {(parent: string, filePath: string) => boolean} */
export function isSubpath(parent, filePath) {
if (filePath.includes('..') || !isAbsolute(filePath)) return false;
parent = trimSlash(parent, { end: true });
return filePath === parent || filePath.startsWith(parent + dirSep);
}
/** @type {(moduleUrl: URL | string) => string} */
export function moduleDirname(moduleUrl) {
return fileURLToPath(new URL('.', moduleUrl));
}
/** @type {(localPath: string) => string} */

@@ -93,0 +109,0 @@ export function pkgFilePath(localPath) {

@@ -119,4 +119,4 @@ import { release } from 'node:os';

/** @type {(data: ReqResMeta) => string} */
export function requestLogLine({ startedAt, endedAt, status, method, urlPath, file, error }) {
const { brackets, style } = color;
export function requestLogLine({ startedAt, endedAt, status, method, urlPath, localPath, error }) {
const { style: _, brackets } = color;

@@ -127,9 +127,9 @@ const isSuccess = status >= 200 && status < 300;

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

@@ -139,8 +139,8 @@ }

const line = [
style(timestamp, 'dim'),
style(`${status}`, isSuccess ? 'green' : 'red'),
style('—', 'dim'),
style(method, 'cyan'),
_(timestamp, 'dim'),
_(`${status}`, isSuccess ? 'green' : 'red'),
_('—', 'dim'),
_(method, 'cyan'),
displayPath,
duration >= 0 ? style(`(${duration}ms)`, 'dim') : undefined,
duration >= 0 ? _(`(${duration}ms)`, 'dim') : undefined,
]

@@ -151,3 +151,3 @@ .filter(Boolean)

if (!isSuccess && error) {
return `${line}\n${style(error.toString(), 'red')}`;
return `${line}\n${_(error.toString(), 'red')}`;
}

@@ -154,0 +154,0 @@ return line;

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

@typedef {import('./types.d.ts').DirIndexItem} DirIndexItem
@typedef {import('./types.d.ts').ResolvedFile} ResolvedFile
@typedef {import('./types.d.ts').ServerOptions} ServerOptions

@@ -81,7 +80,7 @@ */

/**
@param {{ urlPath: string; file: ResolvedFile; items: DirIndexItem[] }} data
@param {{ urlPath: string; filePath: string; items: DirIndexItem[] }} data
@param {Pick<ServerOptions, 'root' | 'ext'>} options
@returns {Promise<string>}
*/
export function dirListPage({ urlPath, file, items }, options) {
export function dirListPage({ urlPath, filePath, items }, options) {
const rootName = basename(options.root);

@@ -97,4 +96,3 @@ const trimmedUrl = trimSlash(urlPath);

sorted.unshift({
filePath: dirname(file.filePath),
localPath: file.localPath && dirname(file.localPath),
filePath: dirname(filePath),
kind: 'dir',

@@ -101,0 +99,0 @@ isParent: true,

@@ -1,4 +0,4 @@

import { join, sep as dirSep } from 'node:path';
import { isAbsolute, join } from 'node:path';
import { getIndex, getKind, getRealpath, isReadable } from './fs-utils.js';
import { getIndex, getKind, getLocalPath, getRealpath, isReadable, isSubpath } from './fs-utils.js';
import { PathMatcher } from './path-matcher.js';

@@ -34,2 +34,4 @@ import { fwdSlash, trimSlash } from './utils.js';

throw new Error('Missing root directory');
} else if (!isAbsolute(options.root)) {
throw new Error('Expected absolute root path');
}

@@ -45,45 +47,36 @@ this.#root = trimSlash(options.root, { end: true });

async find(url) {
const urlPath = this.cleanUrlPath(url);
const { urlPath, filePath: targetPath } = resolveUrlPath(this.#root, url);
/** @type {ResolveResult} */
const result = {
urlPath: urlPath ?? url,
urlPath,
status: 404,
file: null,
filePath: null,
kind: null,
};
const targetPath = this.urlToTargetPath(urlPath);
if (!urlPath || !targetPath || !this.withinRoot(targetPath)) {
if (targetPath == null) {
return result;
}
let resource = await this.locateFile(targetPath);
const isSymlink = resource.kind === 'link';
if (isSymlink) {
const filePath = await getRealpath(resource.filePath);
const kind = filePath ? await getKind(filePath) : null;
if (filePath) {
resource = { filePath, kind };
// Locate file (following symlinks)
let file = await this.locateFile(targetPath);
if (file.kind === 'link') {
const realPath = await getRealpath(file.filePath);
const real = realPath != null ? await this.locateFile(realPath) : null;
if (real?.kind === 'file' || real?.kind === 'dir') {
file = real;
}
}
if (resource.kind === 'dir' || resource.kind === 'file') {
const localPath = this.localPath(resource.filePath);
// We have a match
if (file.kind === 'file' || file.kind === 'dir') {
Object.assign(result, file);
}
result.file = {
filePath: resource.filePath,
localPath: this.localPath(resource.filePath),
kind: resource.kind,
};
const enabled = resource.kind === 'file' || (resource.kind === 'dir' && this.#dirList);
if (enabled && this.allowedLocalPath(localPath)) {
const readable = await isReadable(resource.filePath, resource.kind);
result.status = readable ? 200 : 403;
} else if (isSymlink) {
result.status = 403;
}
// 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);
const readable = allowed && (await isReadable(file.filePath, file.kind));
result.status = allowed ? (readable ? 200 : 403) : 404;
}

@@ -99,11 +92,6 @@

/** @type {DirIndexItem[]} */
const items = [];
const items = (await getIndex(dirPath)).filter(
(item) => item.kind != null && this.allowedPath(item.filePath),
);
for (const { kind, filePath } of await getIndex(dirPath)) {
const localPath = this.localPath(filePath);
if (kind != null && this.allowedLocalPath(localPath)) {
items.push({ filePath, localPath, kind });
}
}
items.sort((a, b) => a.filePath.localeCompare(b.filePath));

@@ -116,9 +104,5 @@

const filePath = await getRealpath(item.filePath);
const kind = filePath ? await getKind(filePath) : null;
if (filePath != null && kind != null) {
item.target = {
kind,
filePath,
localPath: this.localPath(filePath),
};
if (filePath != null && this.withinRoot(filePath)) {
const kind = await getKind(filePath);
item.target = { filePath, kind };
}

@@ -132,66 +116,45 @@ }

/**
Locate alternative files that can be served for a resource,
using the config for extensions and index file lookup.
@type {(fullPath: string) => Promise<FSEntryBase>}
@type {(filePath: string[]) => Promise<FSEntryBase | void>}
*/
async locateFile(fullPath) {
const targetKind = await getKind(fullPath);
if (targetKind === 'file' || targetKind === 'link') {
return { kind: targetKind, filePath: fullPath };
}
/** @type {string[]} */
let candidates = [];
if (targetKind === 'dir' && this.#dirFile.length) {
candidates = this.#dirFile.map((name) => join(fullPath, name));
} else if (targetKind === null && this.#ext.length) {
candidates = this.#ext.map((ext) => fullPath + ext);
}
for (const filePath of candidates) {
async locateAltFiles(filePaths) {
for (const filePath of filePaths) {
if (!this.withinRoot(filePath)) continue;
const kind = await getKind(filePath);
if (kind === 'file' || kind === 'link') {
return { kind, filePath };
return { filePath, kind };
}
}
return { kind: targetKind, filePath: fullPath };
}
/** @type {(localPath: string | null) => boolean} */
allowedLocalPath(localPath) {
if (typeof localPath === 'string') {
return this.#excludeMatcher.test(localPath) === false;
/**
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<FSEntryBase>}
*/
async locateFile(filePath) {
if (!this.withinRoot(filePath)) {
return { filePath, kind: null };
}
return false;
}
/** @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)));
}
const kind = await getKind(filePath);
/** @type {(url: string) => string | null} */
cleanUrlPath(url) {
try {
const path = fwdSlash(new URL(url, 'http://localhost/').pathname);
if (this.allowedUrlPath(path)) {
return path.startsWith('/') ? path : `/${path}`;
}
} catch {}
return null;
// Try alternates
if (kind === 'dir' && this.#dirFile.length) {
const paths = this.#dirFile.map((name) => join(filePath, name));
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);
if (match) return match;
}
return { filePath, kind };
}
/** @type {(fullPath: string) => string | null} */
localPath(fullPath) {
if (this.withinRoot(fullPath)) {
return fullPath.slice(this.#root.length + 1);
}
return null;
/** @type {(filePath: string) => boolean} */
allowedPath(filePath) {
const localPath = getLocalPath(this.#root, filePath);
if (localPath == null) return false;
return this.#excludeMatcher.test(localPath) === false;
}

@@ -208,8 +171,31 @@

/** @type {(fullPath: string) => boolean} */
withinRoot(fullPath) {
if (fullPath.includes('..')) return false;
const prefix = this.#root + dirSep;
return fullPath === this.#root || fullPath.startsWith(prefix);
/** @type {(filePath: string) => boolean} */
withinRoot(filePath) {
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 };
}

@@ -9,2 +9,3 @@ import { Buffer } from 'node:buffer';

import { getContentType, typeForFilePath } from './content-type.js';
import { getLocalPath, getRealpath, isSubpath } from './fs-utils.js';
import { dirListPage, errorPage } from './pages.js';

@@ -20,4 +21,5 @@ import { PathMatcher } from './path-matcher.js';

@typedef {import('./content-type.js').TypeResult} TypeResult
@typedef {import('./types.d.ts').FSEntryBase} FSEntryBase
@typedef {import('./types.d.ts').FSEntryKind} FSEntryKind
@typedef {import('./types.d.ts').ReqResMeta} ReqResMeta
@typedef {import('./types.d.ts').ResolvedFile} ResolvedFile
@typedef {import('./types.d.ts').ServerOptions} ServerOptions

@@ -63,8 +65,7 @@

urlPath = '';
/** @type {string | null} */
filePath = null;
/** @type {FSEntryKind | null} */
kind = null;
/**
File matching the requested urlPath, if found and readable
@type {ResolvedFile | null}
*/
file = null;
/**
Error that may be logged to the terminal

@@ -110,2 +111,8 @@ @type {Error | string | undefined}

}
get localPath() {
if (this.filePath != null) {
return getLocalPath(this.#options.root, this.filePath);
}
return null;
}

@@ -127,15 +134,16 @@ async process() {

const { status, urlPath, file } = await this.#resolver.find(this.url);
const { status, urlPath, filePath, kind } = await this.#resolver.find(this.url);
this.status = status;
this.urlPath = urlPath;
this.file = file;
this.filePath = filePath;
this.kind = kind;
// found a file to serve
if (status === 200 && file?.kind === 'file' && file.localPath != null) {
return this.#sendFile(file);
if (status === 200 && filePath != null && kind === 'file') {
return this.#sendFile(filePath);
}
// found a directory that we can show a listing for
else if (status === 200 && file?.kind === 'dir' && file.localPath != null) {
return this.#sendListPage(file);
else if (status === 200 && filePath != null && kind === 'dir') {
return this.#sendListPage(filePath);
}

@@ -146,4 +154,4 @@

/** @type {(file: ResolvedFile) => Promise<void>} */
async #sendFile(file) {
/** @type {(filePath: string) => Promise<void>} */
async #sendFile(filePath) {
/** @type {FileHandle | undefined} */

@@ -157,7 +165,10 @@ let handle;

try {
// check that we can actually open the file
// (especially on windows where it might be busy)
handle = await open(file.filePath);
statSize = (await stat(file.filePath)).size;
contentType = await getContentType({ path: file.filePath, handle });
// 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);
statSize = (await stat(filePath)).size;
contentType = await getContentType({ path: filePath, handle });
} catch (/** @type {any} */ err) {

@@ -174,3 +185,3 @@ this.status = err?.code === 'EBUSY' ? 403 : 500;

this.#setHeaders(file.localPath ?? file.filePath, {
this.#setHeaders(filePath, {
contentType: contentType?.toString(),

@@ -189,3 +200,3 @@ cors: this.#options.cors,

else if (this.method !== 'HEAD' && !this.#options._noStream) {
data.body = createReadStream(file.filePath, { autoClose: true, start: 0 });
data.body = createReadStream(filePath, { autoClose: true, start: 0 });
}

@@ -196,4 +207,4 @@

/** @type {(dir: ResolvedFile) => Promise<void>} */
async #sendListPage(dir) {
/** @type {(filePath: string) => Promise<void>} */
async #sendListPage(filePath) {
this.#setHeaders('index.html', {

@@ -209,7 +220,5 @@ cors: false,

const items = await this.#resolver.index(dir.filePath);
return this.#send({
body: await dirListPage({ urlPath: this.urlPath, file: dir, items }, this.#options),
isText: true,
});
const items = await this.#resolver.index(filePath);
const body = await dirListPage({ urlPath: this.urlPath, filePath, items }, this.#options);
return this.#send({ body, isText: true });
}

@@ -301,5 +310,5 @@

Set all response headers, except for content-length
@type {(localPath: string, options: Partial<{ contentType: string, cors: boolean; headers: ServerOptions['headers'] }>) => void}
@type {(filePath: string, options: Partial<{ contentType: string, cors: boolean; headers: ServerOptions['headers'] }>) => void}
*/
#setHeaders(localPath, { contentType, cors, headers }) {
#setHeaders(filePath, { contentType, cors, headers }) {
if (this.#res.headersSent) return;

@@ -315,3 +324,3 @@

if (!isOptions) {
contentType ??= typeForFilePath(localPath).toString();
contentType ??= typeForFilePath(filePath).toString();
this.#header('content-type', contentType);

@@ -324,3 +333,4 @@ }

if (localPath && headerRules.length) {
const localPath = getLocalPath(this.#options.root, filePath);
if (localPath != null && headerRules.length) {
const blockList = ['content-encoding', 'content-length'];

@@ -349,4 +359,4 @@ for (const { name, value } of fileHeaders(localPath, headerRules, blockList)) {

data() {
const { startedAt, endedAt, status, method, url, urlPath, file, error } = this;
return { startedAt, endedAt, status, method, url, urlPath, file, error };
const { status, method, url, urlPath, localPath, startedAt, endedAt, error } = this;
return { status, method, url, urlPath, localPath, startedAt, endedAt, error };
}

@@ -353,0 +363,0 @@ }

@@ -1,4 +0,4 @@

export type DirIndexItem = ResolvedFile & {
export type DirIndexItem = FSEntryBase & {
isParent?: boolean;
target?: ResolvedFile;
target?: FSEntryBase;
};

@@ -54,19 +54,17 @@

urlPath: string;
file: ResolvedFile | null;
filePath: string | null;
kind: FSEntryKind | null;
}
export type ReqResMeta = ResolveResult & {
readonly startedAt: number;
export type ReqResMeta = {
method: string;
status: number;
url: string;
urlPath: string;
localPath: string | null;
startedAt: number;
endedAt?: number;
readonly method: string;
readonly url: string;
error?: Error | string;
};
export interface ResolvedFile {
kind: FSEntryKind;
filePath: string;
localPath: string | null;
}
export interface HttpOptions {

@@ -73,0 +71,0 @@ host: string;

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

@@ -45,4 +45,4 @@ "description": "Small, local HTTP server for static files",

"devDependencies": {
"@types/node": "^20.17.1",
"fs-fixture": "^2.5.0",
"@types/node": "^20.17.6",
"fs-fixture": "^2.6.0",
"linkedom": "^0.18.5",

@@ -49,0 +49,0 @@ "prettier": "^3.3.3",

@@ -70,3 +70,3 @@ # servitsy

| [sirv-cli] | 3.0.0 | 12 | 396 kB |
| [serve] | 14.2.3 | 89 | 7.6 MB |
| [serve] | 14.2.4 | 87 | 7.5 MB |
| [http-server] | 14.1.1 | 45 | 8.9 MB |

@@ -73,0 +73,0 @@

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