Comparing version 0.1.2 to 0.1.3
@@ -5,14 +5,4 @@ /** | ||
@typedef {import('./types.js').PortsConfig} PortsConfig | ||
@typedef {{ | ||
default: string; | ||
file: string[]; | ||
extension: string[]; | ||
extensionMap: Record<string, string>; | ||
suffix: string[]; | ||
}} ContentTypes | ||
**/ | ||
const strarr = (s = '') => s.trim().split(/\s+/); | ||
/** @type {readonly string[]} */ | ||
@@ -93,128 +83,1 @@ export const EXTENSIONS_DEFAULT = Object.freeze(['.html']); | ||
}); | ||
export const DEFAULT_CHARSET = 'UTF-8'; | ||
/** @type {ContentTypes} */ | ||
export const TEXT_TYPES = { | ||
default: 'text/plain', | ||
extensionMap: { | ||
atom: 'application/atom+xml', | ||
cjs: 'text/javascript', | ||
css: 'text/css', | ||
csv: 'text/csv', | ||
htm: 'text/html', | ||
html: 'text/html', | ||
ics: 'text/calendar', | ||
js: 'text/javascript', | ||
json: 'application/json', | ||
json5: 'text/plain', | ||
jsonc: 'text/plain', | ||
jsonld: 'application/ld+json', | ||
map: 'application/json', | ||
md: 'text/markdown', | ||
mdown: 'text/markdown', | ||
mjs: 'text/javascript', | ||
rss: 'application/rss+xml', | ||
sql: 'application/sql', | ||
svg: 'image/svg+xml', | ||
text: 'text/plain', | ||
txt: 'text/plain', | ||
xhtml: 'application/xhtml+xml', | ||
xml: 'application/xml', | ||
}, | ||
// Loosely based on npm:textextensions | ||
extension: strarr(` | ||
ada adb ads as ascx asm asmx asp aspx atom | ||
bas bat bbcolors bdsgroup bdsproj bib | ||
c cbl cc cfc cfg cfm cfml cgi clj cls cmake cmd cnf cob coffee conf cpp cpt cpy crt cs cson csr ctl cxx | ||
dart dfm diff dof dpk dproj dtd | ||
eco ejs el emacs eml ent erb erl ex exs | ||
for fpp frm ftn | ||
go gpp gradle groovy groupproj grunit gtmpl | ||
h haml hbs hh hpp hrl hs hta htc hxx | ||
iced inc ini ino int itcl itk | ||
jade java jhtm jhtml js jsp jspx jsx | ||
latex less lhs liquid lisp log ls lsp lua | ||
m mak markdown mdwn mdx metadata mht mhtml mjs mk mkd mkdn mkdown ml mli mm mxml | ||
nfm nfo njk noon | ||
ops pas pasm patch pbxproj pch pem pg php pir pl pm pmc pod pot properties props ps1 pt pug py | ||
r rake rb rdoc resx rhtml rjs rlib rmd ron rs rst rtf rxml | ||
s sass scala scm scss sh shtml sls spec sql sqlite ss sss st strings sty styl stylus sub sv svc svelte | ||
t tcl tex textile tg tmpl toml tpl ts tsv tsx tt tt2 ttml txt | ||
v vb vbs vh vhd vhdl vim vue | ||
wxml wxss x-php xaml xht xs xsd xsl xslt | ||
`), | ||
file: strarr(` | ||
.gitattributes .gitkeep .gitignore .gitmodules | ||
.htaccess .htpasswd | ||
.viminfo .vimrc | ||
`), | ||
suffix: strarr(`config file html ignore rc`), | ||
}; | ||
/** | ||
* @type {ContentTypes} | ||
*/ | ||
export const BIN_TYPES = { | ||
default: 'application/octet-stream', | ||
extensionMap: { | ||
'7z': 'application/x-7z-compressed', | ||
aac: 'audio/aac', | ||
apng: 'image/apng', | ||
aif: 'audio/aiff', | ||
aiff: 'audio/aiff', | ||
avi: 'video/x-msvideo', | ||
avif: 'image/avif', | ||
bmp: 'image/bmp', | ||
bz: 'application/x-bzip', | ||
bz2: 'application/x-bzip2', | ||
doc: 'application/msword', | ||
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', | ||
epub: 'application/epub+zip', | ||
flac: 'audio/flac', | ||
gif: 'image/gif', | ||
gzip: 'application/gzip', | ||
gz: 'application/gzip', | ||
ico: 'image/x-icon', | ||
jpg: 'image/jpg', | ||
jpeg: 'image/jpg', | ||
jxl: 'image/jxl', | ||
jxr: 'image/jxr', | ||
mid: 'audio/midi', | ||
midi: 'audio/midi', | ||
mp3: 'audio/mpeg', | ||
mp4: 'video/mp4', | ||
mpeg: 'video/mpeg', | ||
ods: 'application/vnd.oasis.opendocument.spreadsheet', | ||
odt: 'application/vnd.oasis.opendocument.text', | ||
oga: 'audio/ogg', | ||
ogg: 'audio/ogg', | ||
ogv: 'video/ogg', | ||
opus: 'audio/opus', | ||
otf: 'font/otf', | ||
pdf: 'application/pdf', | ||
ppt: 'application/vnd.ms-powerpoint', | ||
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', | ||
png: 'image/png', | ||
rar: 'application/vnd.rar', | ||
rtf: 'application/rtf', | ||
tar: 'application/x-tar', | ||
tif: 'image/tiff', | ||
tiff: 'image/tiff', | ||
ttf: 'font/ttf', | ||
wav: 'audio/wav', | ||
weba: 'audio/webm', | ||
webm: 'video/webm', | ||
webp: 'image/webp', | ||
woff: 'font/woff', | ||
woff2: 'font/woff2', | ||
xls: 'application/vnd.ms-excel', | ||
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', | ||
yaml: 'application/yaml', | ||
yml: 'application/yaml', | ||
zip: 'application/zip', | ||
}, | ||
extension: strarr('bin exe link msi so'), | ||
file: [], | ||
suffix: [], | ||
}; |
@@ -1,6 +0,5 @@ | ||
import { relative } from 'node:path'; | ||
import { stderr, stdout } from 'node:process'; | ||
import { inspect } from 'node:util'; | ||
import { color, clamp, fwdSlash, withResolvers } from './utils.js'; | ||
import { color, clamp, fwdSlash, withResolvers, trimSlash } from './utils.js'; | ||
@@ -100,13 +99,5 @@ /** | ||
* @param {import('./types.js').ReqResInfo} info | ||
* @returns {string} | ||
*/ | ||
export function requestLogLine({ | ||
filePath, | ||
urlPath, | ||
status, | ||
startedAt, | ||
endedAt, | ||
root, | ||
method, | ||
error, | ||
}) { | ||
export function requestLogLine({ startedAt, endedAt, status, method, urlPath, localPath, error }) { | ||
const { brackets, style } = color; | ||
@@ -118,11 +109,7 @@ | ||
let displayPath = style(urlPath, 'cyan'); | ||
const fileUrlPath = filePath ? '/' + fwdSlash(relative(root, filePath)) : undefined; | ||
if (isSuccess && fileUrlPath && fileUrlPath != '/') { | ||
const hasSuffix = fileUrlPath.startsWith(urlPath) && !fileUrlPath.endsWith(urlPath); | ||
const suffix = hasSuffix | ||
? fileUrlPath.slice(fileUrlPath.lastIndexOf(urlPath) + urlPath.length) | ||
: ''; | ||
if (suffix.length > 1) { | ||
displayPath += brackets(suffix, 'dim,gray,dim'); | ||
if (isSuccess && localPath) { | ||
const basePath = urlPath.length > 1 ? trimSlash(urlPath, { end: true }) : urlPath; | ||
const suffix = pathSuffix(basePath, `/${fwdSlash(localPath)}`); | ||
if (suffix) { | ||
displayPath = style(basePath, 'cyan') + brackets(suffix, 'dim,gray,dim'); | ||
} | ||
@@ -144,1 +131,14 @@ } | ||
} | ||
/** | ||
* @param {string} basePath | ||
* @param {string} fullPath | ||
* @returns {string | undefined} | ||
*/ | ||
function pathSuffix(basePath, fullPath) { | ||
if (basePath === fullPath) { | ||
return ''; | ||
} else if (fullPath.startsWith(basePath)) { | ||
return fullPath.slice(basePath.length); | ||
} | ||
} |
@@ -61,3 +61,3 @@ import { access, constants, lstat, readdir, realpath } from 'node:fs/promises'; | ||
* @param {import('node:fs').Dirent | import('node:fs').StatsBase<any>} stats | ||
* @returns {import('./types.js').FSEntryKind} | ||
* @returns {import('./types.js').FSEntryKind | null} | ||
*/ | ||
@@ -64,0 +64,0 @@ function statsKind(stats) { |
import { readFile } from 'node:fs/promises'; | ||
import { basename, join } from 'node:path'; | ||
import { basename, dirname, join } from 'node:path'; | ||
import { clamp, escapeHtml, fwdSlash, getDirname, trimSlash } from './utils.js'; | ||
import { clamp, escapeHtml, getDirname, trimSlash } from './utils.js'; | ||
/** | ||
@typedef {import('./types.js').DirIndexItem} DirIndexItem | ||
@typedef {import('./types.js').ResolvedFile} ResolvedFile | ||
@typedef {import('./types.js').ServerOptions} ServerOptions | ||
@@ -29,3 +30,3 @@ **/ | ||
} else { | ||
const contents = await readFile(fullPath, { encoding: 'utf-8' }); | ||
const contents = await readFile(fullPath, { encoding: 'utf8' }); | ||
assetCache.set(fullPath, contents); | ||
@@ -93,18 +94,19 @@ return contents; | ||
/** | ||
* @param {{ dirPath: string; urlPath: string, items: DirIndexItem[] }} data | ||
* @param {{ urlPath: string; file: ResolvedFile; items: DirIndexItem[] }} data | ||
* @param {Pick<ServerOptions, 'root' | 'ext'>} options | ||
* @returns {Promise<string>} | ||
*/ | ||
export function dirListPage({ dirPath, urlPath, items }, options) { | ||
let baseUrl = '/'; | ||
let displayPath = basename(options.root); | ||
export function dirListPage({ urlPath, file, items }, options) { | ||
const rootName = basename(options.root); | ||
const trimmedUrl = trimSlash(urlPath); | ||
const baseUrl = trimmedUrl ? `/${trimmedUrl}/` : '/'; | ||
const displayPath = decodeURIPathSegments(trimmedUrl ? `${rootName}/${trimmedUrl}` : rootName); | ||
const sorted = [...items.filter((x) => isDirLike(x)), ...items.filter((x) => !isDirLike(x))]; | ||
const trimmedUrlPath = trimSlash(urlPath); | ||
if (trimmedUrlPath) { | ||
baseUrl = `/${trimmedUrlPath}/`; | ||
displayPath = decodeURIPathSegments(`${displayPath}/${trimmedUrlPath}`); | ||
if (trimmedUrl !== '') { | ||
sorted.unshift({ | ||
filePath: join(dirPath, '..'), | ||
filePath: dirname(file.filePath), | ||
localPath: file.localPath && dirname(file.localPath), | ||
kind: 'dir', | ||
@@ -111,0 +113,0 @@ isParent: true, |
@@ -56,6 +56,5 @@ import { fwdSlash, trimSlash } from './utils.js'; | ||
const result = { | ||
urlPath: urlPath ?? url, | ||
status: 404, | ||
kind: null, | ||
filePath: null, | ||
urlPath: urlPath ?? url, | ||
file: null, | ||
}; | ||
@@ -81,6 +80,13 @@ | ||
if (resource.kind === 'dir' || resource.kind === 'file') { | ||
Object.assign(result, resource); | ||
const localPath = this.localPath(resource.filePath); | ||
result.file = { | ||
filePath: resource.filePath, | ||
localPath: this.localPath(resource.filePath), | ||
kind: resource.kind, | ||
}; | ||
const enabled = resource.kind === 'file' || (resource.kind === 'dir' && this.#dirList); | ||
const allowed = this.allowedPath(resource.filePath); | ||
if (enabled && allowed) { | ||
if (enabled && this.allowedPath(localPath)) { | ||
const readable = await this.#fsUtils.readable(resource.filePath, resource.kind); | ||
@@ -107,34 +113,45 @@ result.status = readable ? 200 : 403; | ||
return { kind: targetKind, filePath: targetPath }; | ||
} else if (targetKind === 'dir') { | ||
const candidates = this.#dirFile.map((name) => this.#fsUtils.join(targetPath, name)); | ||
for (const file of candidates) { | ||
const kind = await this.#fsUtils.kind(file); | ||
if (kind === 'file' || kind === 'link') { | ||
return { kind, filePath: file }; | ||
} | ||
} | ||
/** @type {string[]} */ | ||
let candidates = []; | ||
if (targetKind === 'dir' && this.#dirFile.length) { | ||
candidates = this.#dirFile.map((name) => this.#fsUtils.join(targetPath, name)); | ||
} else if (targetKind === null && this.#ext.length) { | ||
candidates = this.#ext.map((ext) => targetPath + ext); | ||
} | ||
for (const filePath of candidates) { | ||
const kind = await this.#fsUtils.kind(filePath); | ||
if (kind === 'file' || kind === 'link') { | ||
return { kind, filePath }; | ||
} | ||
return { kind: targetKind, filePath: targetPath }; | ||
} else { | ||
const candidates = this.#ext.map((ext) => targetPath + ext); | ||
for (const file of candidates) { | ||
const kind = await this.#fsUtils.kind(file); | ||
if (kind === 'file') return { kind, filePath: file }; | ||
} | ||
return { kind: null, filePath: targetPath }; | ||
} | ||
return { kind: targetKind, filePath: targetPath }; | ||
} | ||
/** | ||
* @param {string} resourcePath | ||
* @param {string | null} localPath | ||
* @returns {boolean} | ||
*/ | ||
allowedPath(resourcePath) { | ||
if (!this.withinRoot(resourcePath)) { | ||
return false; | ||
allowedPath(localPath) { | ||
if (typeof localPath === 'string') { | ||
return this.#excludeMatcher.test(localPath) === false; | ||
} | ||
const subPath = this.#fsUtils.relative(this.#root, resourcePath); | ||
return this.#excludeMatcher.test(subPath) === false; | ||
return false; | ||
} | ||
/** | ||
* @param {string} filePath | ||
* @returns {string | null} | ||
*/ | ||
localPath(filePath) { | ||
if (this.withinRoot(filePath)) { | ||
return filePath.slice(this.#root.length + 1); | ||
} | ||
return null; | ||
} | ||
/** | ||
* @param {string} dirPath | ||
@@ -146,18 +163,29 @@ * @returns {Promise<DirIndexItem[]>} | ||
const entries = (await this.#fsUtils.index(dirPath)).filter((entry) => | ||
this.allowedPath(entry.filePath), | ||
); | ||
entries.sort((a, b) => a.filePath.localeCompare(b.filePath)); | ||
/** @type {DirIndexItem[]} */ | ||
const items = []; | ||
for (const { kind, filePath } of await this.#fsUtils.index(dirPath)) { | ||
const localPath = this.localPath(filePath); | ||
if (kind != null && this.allowedPath(localPath)) { | ||
items.push({ filePath, localPath, kind }); | ||
} | ||
} | ||
items.sort((a, b) => a.filePath.localeCompare(b.filePath)); | ||
return Promise.all( | ||
entries.map(async (entry) => { | ||
items.map(async (item) => { | ||
// resolve symlinks | ||
if (entry.kind === 'link') { | ||
const filePath = await this.#fsUtils.realpath(entry.filePath); | ||
const kind = filePath && (await this.#fsUtils.kind(filePath)); | ||
if (filePath && kind) { | ||
return { ...entry, target: { filePath, kind } }; | ||
if (item.kind === 'link') { | ||
const filePath = await this.#fsUtils.realpath(item.filePath); | ||
const kind = filePath ? await this.#fsUtils.kind(filePath) : null; | ||
if (filePath != null && kind != null) { | ||
item.target = { | ||
kind, | ||
filePath, | ||
localPath: this.localPath(filePath), | ||
}; | ||
} | ||
} | ||
return entry; | ||
return item; | ||
}), | ||
@@ -206,10 +234,9 @@ ); | ||
/** | ||
* @param {string} resourcePath | ||
* @param {string} filePath | ||
* @returns {boolean} | ||
*/ | ||
withinRoot(resourcePath) { | ||
return ( | ||
!resourcePath.includes('..') && | ||
(resourcePath === this.#root || resourcePath.startsWith(this.#root + this.#fsUtils.dirSep)) | ||
); | ||
withinRoot(filePath) { | ||
if (filePath.includes('..')) return false; | ||
const prefix = this.#root + this.#fsUtils.dirSep; | ||
return filePath === this.#root || filePath.startsWith(prefix); | ||
} | ||
@@ -216,0 +243,0 @@ } |
import { open } from 'node:fs/promises'; | ||
import { createServer } from 'node:http'; | ||
import { join } from 'node:path'; | ||
import { getContentType, typeForFilePath } from './content-type.js'; | ||
import { fsUtils } from './node-fs.js'; | ||
import { dirListPage, errorPage } from './pages.js'; | ||
import { FileResolver, PathMatcher } from './resolver.js'; | ||
import { contentType } from './utils.js'; | ||
/** | ||
@typedef {import('./types.js').DirIndexItem} DirIndexItem | ||
@typedef {import('./types.js').FSEntryKind} FSEntryKind | ||
@typedef {import('./types.js').ReqResInfo} ReqResInfo | ||
@typedef {import('./types.js').ResolvedFile} ResolvedFile | ||
@typedef {import('./types.js').ResolveResult} ResolveResult | ||
@@ -20,3 +21,3 @@ @typedef {import('./types.js').ServerOptions} ServerOptions | ||
* @param {{ logNetwork?: (info: ReqResInfo) => void }} callbacks | ||
* @returns {ReturnType<createServer>} | ||
* @returns {import('node:http').Server} | ||
*/ | ||
@@ -27,5 +28,6 @@ export function staticServer(options, { logNetwork }) { | ||
return createServer(async (req, res) => { | ||
/** @type {Pick<ReqResInfo, 'root' | 'method' | 'startedAt' | 'error'>} */ | ||
const info = { | ||
root: options.root, | ||
/** | ||
* @type {Pick<ReqResInfo, 'method' | 'startedAt' | 'error'>} | ||
*/ | ||
const logInfo = { | ||
method: req.method ?? '', | ||
@@ -35,33 +37,51 @@ startedAt: Date.now(), | ||
const urlPath = | ||
typeof req.url === 'string' ? new URL(req.url, 'http://localhost/').pathname : '/'; | ||
const result = await resolver.find(urlPath); | ||
const { file, ...result } = await resolver.find(req.url ?? ''); | ||
if (logNetwork) { | ||
res.on('close', () => | ||
res.on('close', () => { | ||
logNetwork({ | ||
...result, | ||
...info, | ||
status: result.status, | ||
urlPath: result.urlPath, | ||
localPath: file?.localPath ?? null, | ||
...logInfo, | ||
endedAt: Date.now(), | ||
}), | ||
); | ||
}); | ||
}); | ||
} | ||
// found a file to serve | ||
if (result.kind === 'file' && result.status === 200 && result.filePath) { | ||
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) | ||
const fileHandle = await open(result.filePath); | ||
const stream = fileHandle.createReadStream(); | ||
const headers = fileHeaders(result.filePath, options); | ||
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' && err.code === 'EBUSY') { | ||
result.status = 403; | ||
if (err?.syscall === 'open') { | ||
if (err.code === 'EBUSY') result.status = 403; | ||
fileHandle?.close(); | ||
} | ||
if (err?.message) { | ||
info.error = err; | ||
logInfo.error = err; | ||
} | ||
@@ -73,19 +93,10 @@ await sendErrorPage(res, result, options); | ||
// found a directory that we can show a listing for | ||
else if (result.kind === 'dir' && result.status === 200 && result.filePath) { | ||
const headers = fileHeaders( | ||
join(result.filePath, 'index.html'), | ||
// ignore user options for directory listings | ||
{ cors: false, headers: [] }, | ||
); | ||
const body = await dirListPage( | ||
{ | ||
urlPath: result.urlPath, | ||
dirPath: result.filePath, | ||
items: await resolver.index(result.filePath), | ||
}, | ||
options, | ||
); | ||
res.writeHead(result.status, headers); | ||
res.write(body); | ||
res.end(); | ||
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); | ||
} | ||
@@ -102,15 +113,31 @@ | ||
* @param {import('node:http').ServerResponse} res | ||
* @param {ResolveResult} result | ||
* @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(); | ||
} | ||
/** | ||
* @param {import('node:http').ServerResponse} res | ||
* @param {Pick<ResolveResult, 'status' | 'urlPath'>} result | ||
* @param {ServerOptions} options | ||
*/ | ||
async function sendErrorPage(res, result, options) { | ||
const headers = fileHeaders( | ||
join(options.root, 'error.html'), | ||
const headers = fileHeaders({ | ||
localPath: 'error.html', | ||
cors: options.cors, | ||
// ignore custom headers for error pages | ||
{ cors: options.cors, headers: [] }, | ||
); | ||
const body = await errorPage({ | ||
status: result.status, | ||
urlPath: result.urlPath, | ||
headers: [], | ||
}); | ||
const body = await errorPage(result); | ||
res.writeHead(result.status, headers); | ||
@@ -122,13 +149,12 @@ res.write(body); | ||
/** | ||
* @param {string} filePath | ||
* @param {Pick<ServerOptions, 'headers' | 'cors'>} options | ||
* @param {{ localPath: string; contentType?: string; cors: boolean; headers: ServerOptions['headers'] }} data | ||
* @returns {Record<string, string>} | ||
*/ | ||
export function fileHeaders(filePath, { cors, headers }) { | ||
export function fileHeaders({ localPath, contentType, cors, headers }) { | ||
/** @type {Record<string, string>} */ | ||
const result = {}; | ||
const setHeader = (key = '', value = '') => (result[key.toLowerCase()] = value); | ||
setHeader('content-type', contentType(filePath)); | ||
const obj = {}; | ||
const add = (key = '', value = '') => (obj[key.trim().toLowerCase()] = value); | ||
add('content-type', contentType || typeForFilePath(localPath).toString()); | ||
if (cors) { | ||
setHeader('access-control-allow-origin', '*'); | ||
add('access-control-allow-origin', '*'); | ||
} | ||
@@ -138,9 +164,9 @@ for (const rule of headers) { | ||
const matcher = new PathMatcher(rule.include); | ||
if (!matcher.test(filePath)) continue; | ||
if (!matcher.test(localPath)) continue; | ||
} | ||
for (const [key, value] of Object.entries(rule.headers)) { | ||
setHeader(key, value); | ||
add(key, value); | ||
} | ||
} | ||
return result; | ||
return obj; | ||
} |
@@ -14,4 +14,4 @@ /** | ||
@typedef {'dir' | 'file' | 'link' | null} FSEntryKind | ||
@typedef {{ filePath: string; kind: FSEntryKind; symlink?: boolean }} FSEntryBase | ||
@typedef {'dir' | 'file' | 'link'} FSEntryKind | ||
@typedef {{ filePath: string; kind: FSEntryKind | null }} FSEntryBase | ||
@@ -24,4 +24,4 @@ @typedef {{ | ||
info(filePath: string): Promise<FSEntryBase & {readable: boolean}>; | ||
kind(filePath: string): Promise<FSEntryKind>; | ||
readable(filePath: string, kind?: FSEntryKind): Promise<boolean>; | ||
kind(filePath: string): Promise<FSEntryKind | null>; | ||
readable(filePath: string, kind?: FSEntryKind | null): Promise<boolean>; | ||
realpath(filePath: string): Promise<string | null>; | ||
@@ -53,15 +53,22 @@ }} FSUtils | ||
@typedef {{ | ||
kind: FSEntryKind; | ||
filePath: string; | ||
localPath: string | null; | ||
}} ResolvedFile | ||
@typedef {{ | ||
status: number; | ||
urlPath: string; | ||
filePath: string | null; | ||
kind: FSEntryKind; | ||
file: ResolvedFile | null; | ||
}} ResolveResult | ||
@typedef {FSEntryBase & { isParent?: boolean; target?: FSEntryBase }} DirIndexItem | ||
@typedef {ResolvedFile & {isParent?: boolean; target?: ResolvedFile}} DirIndexItem | ||
@typedef {ResolveResult & { | ||
method: string; | ||
root: string; | ||
@typedef {{ | ||
startedAt: number; | ||
endedAt?: number; | ||
status: number; | ||
method: string; | ||
urlPath: string; | ||
localPath: string | null; | ||
error?: Error | string; | ||
@@ -68,0 +75,0 @@ }} ReqResInfo |
@@ -1,6 +0,4 @@ | ||
import { basename, extname } from 'node:path'; | ||
import { fileURLToPath } from 'node:url'; | ||
import { ColorUtils } from './color.js'; | ||
import { DEFAULT_CHARSET, BIN_TYPES, TEXT_TYPES } from './constants.js'; | ||
@@ -18,29 +16,2 @@ export const color = new ColorUtils(); | ||
/** | ||
* @type {(filename: string, charset?: string | null) => string} | ||
*/ | ||
export function contentType(filename, charset = DEFAULT_CHARSET) { | ||
const charsetSuffix = charset ? `; charset=${charset}` : ''; | ||
const name = basename(filename).toLowerCase(); | ||
const ext = extname(filename).replace('.', '').toLowerCase(); | ||
if (ext) { | ||
if (Object.hasOwn(TEXT_TYPES.extensionMap, ext)) { | ||
return TEXT_TYPES.extensionMap[ext] + charsetSuffix; | ||
} else if (Object.hasOwn(BIN_TYPES.extensionMap, ext)) { | ||
return BIN_TYPES.extensionMap[ext]; | ||
} else if (TEXT_TYPES.extension.includes(ext)) { | ||
return TEXT_TYPES.default + charsetSuffix; | ||
} else if (BIN_TYPES.extension.includes(ext)) { | ||
return BIN_TYPES.default; | ||
} | ||
} else { | ||
if (TEXT_TYPES.file.includes(name) || TEXT_TYPES.suffix.find((x) => name.endsWith(x))) { | ||
return TEXT_TYPES.default + charsetSuffix; | ||
} | ||
} | ||
return BIN_TYPES.default; | ||
} | ||
/** | ||
* @type {(input: string, context?: 'text' | 'attr') => string} | ||
@@ -47,0 +18,0 @@ */ |
{ | ||
"name": "servitsy", | ||
"version": "0.1.2", | ||
"version": "0.1.3", | ||
"keywords": [ | ||
@@ -38,3 +38,3 @@ "cli", | ||
"test": "node --test --test-reporter=spec", | ||
"typecheck": "tsc -p jsconfig.json" | ||
"typecheck": "tsc -p jsconfig.json && tsc -p test/jsconfig.json" | ||
}, | ||
@@ -41,0 +41,0 @@ "devDependencies": { |
@@ -5,7 +5,7 @@ # servitsy | ||
- Small: zero dependencies, 24 kilobytes gzipped. | ||
- What: for your local testing needs. | ||
- How: with decent defaults, and no cool features. | ||
- **Small:** no dependencies, 25 kilobytes gzipped. | ||
- **Static:** serves static files and directory listings. | ||
- **Local:** designed for single-user local workflows, not for production. | ||
## Quick start | ||
## Usage | ||
@@ -21,41 +21,18 @@ ```sh | ||
- serve `index.html` files for folders, and `.html` files when the extension was omitted in the URL; | ||
- show directory contents (for folders without an index file). | ||
- list directory contents (for folders without an index file). | ||
See `npx servitsy --help` — or [the Options section](#options) — if you want to configure this behavior. | ||
You can configure this behavior [with options](#options). Here are a couple examples: | ||
## When you shouldn’t use this package | ||
```sh | ||
# serve current folder on port 3000, with CORS headers | ||
npx servitsy -p 3000 --cors | ||
### ⛔️ In production | ||
# serve 'dist' folder and disable directory listings | ||
npx servitsy dist --dir-list false | ||
``` | ||
There are safer and faster tools to serve a folder of static HTML to the public. Apache, Nginx, fastify-static, etc. | ||
## Options | ||
### 🤔 For web app development… | ||
See `npx servitsy --help` for an overview of available options. | ||
… if you want nice dev features like live-reload, transpilation, bundling, etc. — use something like [Vite](https://vitejs.dev/) instead. | ||
### 🌈 If you love another | ||
There are good established alternatives to this package. Here is a brief and subjective comparison of a few packages I like: | ||
| Package | Size on disk† | Dependencies | Highlights | | ||
| ----------------------- | ------------- | ------------ | -------------------------- | | ||
| servitsy (v0.1.2) | 112 kB | 0 | Tiny | | ||
| [servor] (v4.0.2) | 144 kB | 0 | Tiny, some cool features | | ||
| [sirv-cli] (v2.0.2) | 392 kB | 12 | Small, good options | | ||
| [serve] (v14.2.3) | 7.6 MB | 89 | Good defaults, easy to use | | ||
| [http-server] (v14.1.1) | 8.9 MB | 45 | Good defaults, featureful | | ||
The philosophy of `servitsy` is to have few opinions and bells and whistles (like `sirv-cli`), and to try to offer that in a zero-dependency package (like `servor`). | ||
If size and dependency count is not a concern and you want something stable and battle-tested, I recommend `serve` and `http-server`. | ||
† Size on disk is the uncompressed size of the package and its dependencies (as reported by `/usr/bin/du` on macOS with an APFS filesystem; exact size may depend on the OS and/or filesystem). | ||
[http-server]: https://www.npmjs.com/package/serve | ||
[serve]: https://www.npmjs.com/package/serve | ||
[servor]: https://www.npmjs.com/package/servor | ||
[sirv-cli]: https://www.npmjs.com/package/sirv-cli | ||
## Options | ||
### `cors` | ||
@@ -184,1 +161,28 @@ | ||
Defaults to `8080+`. | ||
## Alternatives | ||
> __🚨 Reminder: `servitsy` is not designed for production.__ There are safer and faster tools to serve a folder of static HTML to the public. See Apache, Nginx, [@fastify/static], etc. | ||
For local testing, here are a few established alternatives you may prefer, with their respective size: | ||
| Package | Version | Dependencies | Size on disk† | | ||
| ------------- | ------- | ------------ | ------------- | | ||
| [servitsy] | 0.1.3 | 0 | 124 kB | | ||
| [servor] | 4.0.2 | 0 | 144 kB | | ||
| [sirv-cli] | 2.0.2 | 12 | 392 kB | | ||
| [serve] | 14.2.3 | 89 | 7.6 MB | | ||
| [http-server] | 14.1.1 | 45 | 8.9 MB | | ||
If size and dependency count is not a concern and you want something stable and battle-tested, I recommend [serve] and [http-server]. | ||
Otherwise, [servor], [sirv-cli] or [servitsy] might work for you. | ||
_† Size on disk 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)._ | ||
[@fastify/static]: https://www.npmjs.com/package/@fastify/static | ||
[http-server]: https://www.npmjs.com/package/http-server | ||
[serve]: https://www.npmjs.com/package/serve | ||
[servitsy]: https://www.npmjs.com/package/servitsy | ||
[servor]: https://www.npmjs.com/package/servor | ||
[sirv-cli]: https://www.npmjs.com/package/sirv-cli |
Sorry, the diff of this file is not supported yet
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
79828
22
2417
186