Comparing version 0.1.3 to 0.2.0
@@ -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 | |
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
85865
2618
5