diff --git a/README.md b/README.md index b95c1ec..4e8f0db 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,19 @@ servitsy --ext '.html' # default servitsy --ext '.xhtml' --ext '.html' ``` +### `gzip` + +Enables gzip compression for text files. Defaults to `true`. + +```sh +# Enable (default) +servitsy --gzip +servitsy --gzip true + +# Disable +servitsy --gzip false +``` + ### `header` Add custom HTTP headers to responses, for all files or specific file patterns. Headers can be provided using a `header:value` syntax, or as a JSON string: diff --git a/lib/cli.js b/lib/cli.js index f7f66f7..f6c3b92 100644 --- a/lib/cli.js +++ b/lib/cli.js @@ -34,7 +34,7 @@ export async function run() { return; } - const { errors, options } = serverOptions({}, args); + const { errors, options } = serverOptions({ args }); if (errors.length) { logger.writeErrors(errors); @@ -203,6 +203,7 @@ export function helpPage() { 'port', 'header', 'cors', + 'gzip', 'ext', 'dirFile', 'dirList', diff --git a/lib/constants.js b/lib/constants.js index cdb8854..8b14b09 100644 --- a/lib/constants.js +++ b/lib/constants.js @@ -22,6 +22,8 @@ export const HOSTS_WILDCARD = Object.freeze({ v6: '::', }); +export const MAX_COMPRESS_SIZE = 50_000_000; + /** @type {PortsConfig} */ export const PORTS_CONFIG = Object.freeze({ initial: 8080, @@ -29,8 +31,8 @@ export const PORTS_CONFIG = Object.freeze({ countLimit: 100, }); -/** @type {string[]} */ -export const SUPPORTED_METHODS = ['GET', 'HEAD', 'OPTIONS', 'POST']; +/** @type {readonly string[]} */ +export const SUPPORTED_METHODS = Object.freeze(['GET', 'HEAD', 'OPTIONS', 'POST']); /** @type {Record} */ export const CLI_OPTIONS = Object.freeze({ @@ -59,6 +61,11 @@ export const CLI_OPTIONS = Object.freeze({ argDefault: EXTENSIONS_DEFAULT.join(', '), help: 'Extensions which can be omitted in URLs', }, + gzip: { + args: ['--gzip'], + argDefault: 'true', + help: 'Use gzip compression for text files', + }, header: { args: ['--header'], help: 'Add custom HTTP header(s) to responses', diff --git a/lib/content-type.js b/lib/content-type.js index b19784f..f2f143e 100644 --- a/lib/content-type.js +++ b/lib/content-type.js @@ -143,7 +143,7 @@ const BIN_TYPES = { suffix: [], }; -class TypeResult { +export class TypeResult { /** @type {'text' | 'bin' | 'unknown'} */ group = 'unknown'; @@ -237,25 +237,21 @@ export async function typeForFile(fileHandle, charset) { } /** - * @param {{ filePath?: string; fileHandle?: import('node:fs/promises').FileHandle; charset?: string | null }} data - * @returns {Promise} + * @param {{ filePath?: string; fileHandle?: import('node:fs/promises').FileHandle }} data + * @returns {Promise} */ -export async function getContentType({ filePath, fileHandle, charset }) { +export async function getContentType({ filePath, fileHandle }) { if (filePath) { - const result = typeForFilePath(filePath, charset); + const result = typeForFilePath(filePath); if (result.group !== 'unknown') { - return result.toString(); + return result; } } - if (fileHandle) { - const result = await typeForFile(fileHandle, charset); - if (result.group !== 'unknown') { - return result.toString(); - } + const result = await typeForFile(fileHandle); + return result; } - - return BIN_TYPES.default; + return new TypeResult().unknown(); } /** diff --git a/lib/options.js b/lib/options.js index c8da3ea..4ca1e73 100644 --- a/lib/options.js +++ b/lib/options.js @@ -37,235 +37,52 @@ export function validateArgPresence(args, { warn }) { } /** - * @param {Partial} options - * @param {CLIArgs} [args] + * @param {Partial & {args?: CLIArgs}} options * @returns {{errors: ErrorMessage[]; options: ListenOptions & ServerOptions}} */ -export function serverOptions(options, args) { - const mode = args ? 'arg' : 'option'; +export function serverOptions({ args, ...options }) { /** @type {ValidationContext} */ - const context = { mode, ...errorsContext() }; + const context = { + mode: args ? 'arg' : 'option', + ...errorsContext(), + }; if (args) { validateArgPresence(args, context); + return { + options: { + root: validateRoot(args.get(0), context), + host: validateHost(args.get(CLI_OPTIONS.host.args), context), + ports: validatePorts(args.get(CLI_OPTIONS.port.args), { ...context, config: PORTS_CONFIG }), + ext: validateExt(args.all(CLI_OPTIONS.ext.args), context), + dirFile: validateDirFile(args.all(CLI_OPTIONS.dirFile.args), context), + dirList: validateDirList(args.get(CLI_OPTIONS.dirList.args), context), + exclude: validateExclude(args.all(CLI_OPTIONS.exclude.args), context), + cors: validateCors(args.get(CLI_OPTIONS.cors.args), context), + headers: validateHeaders(args.all(CLI_OPTIONS.header.args), context), + gzip: validateGzip(args?.get(CLI_OPTIONS.gzip.args), context), + }, + errors: context.errors, + }; } - const root = validateRoot(mode === 'arg' ? args?.get(0) : options.root, context); - const ports = validatePorts(mode === 'arg' ? args?.get(CLI_OPTIONS.port.args) : options.ports, { - ...context, - config: PORTS_CONFIG, - }); - const host = validateHost( - mode === 'arg' ? args?.get(CLI_OPTIONS.host.args) : options.host, - context, - ); - - const ext = validateExt(mode === 'arg' ? args?.all(CLI_OPTIONS.ext.args) : options.ext, context); - const dirFile = validateDirFile( - mode === 'arg' ? args?.all(CLI_OPTIONS.dirFile.args) : options.dirFile, - context, - ); - const dirList = validateDirList( - mode === 'arg' ? args?.get(CLI_OPTIONS.dirList.args) : options.dirList, - context, - ); - const exclude = validateExclude( - mode === 'arg' ? args?.all(CLI_OPTIONS.exclude.args) : options.exclude, - context, - ); - const cors = validateCors( - mode === 'arg' ? args?.get(CLI_OPTIONS.cors.args) : options.cors, - context, - ); - const headers = validateHeaders( - mode === 'arg' ? args?.all(CLI_OPTIONS.header.args) : options.headers, - context, - ); - return { - errors: context.errors, options: { - root, - host, - ports, - ext, - dirFile, - dirList, - exclude, - cors, - headers, + root: validateRoot(options.root, context), + host: validateHost(options.host, context), + ports: validatePorts(options.ports, { ...context, config: PORTS_CONFIG }), + ext: validateExt(options.ext, context), + dirFile: validateDirFile(options.dirFile, context), + dirList: validateDirList(options.dirList, context), + exclude: validateExclude(options.exclude, context), + cors: validateCors(options.cors, context), + headers: validateHeaders(options.headers, context), + gzip: validateGzip(options.gzip, context), }, + errors: context.errors, }; } -/** - * @param {string | boolean | undefined} input - * @param {ValidationContext} context - */ -export function validateDirList(input, context) { - return validateBoolean(input, { - ...context, - optName: context.mode === 'arg' ? '--dir-list' : 'dirList', - defaultValue: true, - emptyValue: true, - }); -} - -/** - * @param {string | boolean | undefined} input - * @param {ValidationContext} context - */ -export function validateCors(input, context) { - return validateBoolean(input, { - ...context, - optName: context.mode === 'arg' ? '--cors' : 'cors', - defaultValue: false, - emptyValue: true, - }); -} - -/** - * @param {string[] | HttpHeaderRule[] | undefined} input - * @param {ValidationContext} context - * @returns {HttpHeaderRule[]} - */ -export function validateHeaders(input, context) { - const optName = context.mode === 'arg' ? '--header' : 'headers'; - const setError = (/** @type {any} */ rule) => - context.error(`invalid ${optName} value: ${JSON.stringify(rule)}`); - - if (context.mode === 'arg' && Array.isArray(input) && input.length > 0) { - return input - .map((item) => - typeof item === 'string' ? parseHeaders(item, { ...context, optName }) : undefined, - ) - .filter((item) => item != null) - .filter((item) => { - const ok = isValidHeaderRule(item); - if (!ok) setError(item); - return ok; - }); - } - - if (context.mode === 'option' && Array.isArray(input) && input.length > 0) { - return input.filter((item) => { - const ok = isValidHeaderRule(item); - if (!ok) setError(item); - return ok; - }); - } - - return []; -} - -/** - * @param {string} input - * @param {ValidationContext & { optName: string }} context - * @returns {HttpHeaderRule | undefined} - */ -export function parseHeaders(input, { optName, warn }) { - input = input.trim(); - const colonPos = input.indexOf(':'); - const bracketPos = input.indexOf('{'); - - /** @type {(include?: string, entries?: string[][]) => HttpHeaderRule} */ - const makeRule = (include = '', entries = []) => { - const headers = Object.fromEntries(entries); - return include.length > 0 && include !== '*' - ? { headers, include: include.split(',').map((s) => s.trim()) } - : { headers }; - }; - - if (bracketPos >= 0 && colonPos > bracketPos && input.endsWith('}')) { - const jsonStart = input.indexOf('{'); - const include = input.slice(0, jsonStart).trim(); - const json = input.slice(jsonStart); - let obj; - try { - obj = JSON.parse(json); - } catch {} - if (obj != null && typeof obj === 'object') { - const valTypes = ['string', 'boolean', 'number']; - const entries = Object.entries(obj) - .map(([key, val]) => [ - typeof key === 'string' ? key : '', - valTypes.includes(typeof val) ? String(val) : '', - ]) - .filter((entry) => entry[0].length > 0 && entry[1].length > 0); - if (entries.length > 0) { - return makeRule(include, entries); - } - } else { - warn(`could not parse ${optName} value: '${json}'`); - } - } else if (colonPos > 0) { - const key = input.slice(0, colonPos).trim(); - const val = input.slice(colonPos + 1).trim(); - if (key && val) { - const header = key.split(/\s+/).at(-1) ?? key; - const include = header === key ? undefined : key.slice(0, key.indexOf(header)).trim(); - return makeRule(include, [[header, val]]); - } else { - warn(`could not parse ${optName} value: '${input}'`); - } - } else if (input) { - warn(`invalid ${optName} value: '${input}'`); - } -} - -/** @type {(value: any) => value is HttpHeaderRule} */ -function isValidHeaderRule(value) { - const headerRegex = /^[A-Za-z0-9\-\_]+$/; - const include = value?.include; - const headers = value?.headers; - if (Array.isArray(include) && include.some((item) => typeof item !== 'string')) { - return false; - } - if (headers == null || typeof headers !== 'object') { - return false; - } - const entries = Object.entries(headers); - return ( - entries.length > 0 && - entries.every(([key, value]) => { - return ( - typeof key === 'string' && - headerRegex.test(key) && - typeof value === 'string' && - value.length > 0 - ); - }) - ); -} - -/** - * @param {string[] | undefined} input - * @param {ValidationContext} context - * @returns {string[]} - */ -export function validateExclude(input, { mode, warn }) { - const name = mode === 'arg' ? '--exclude' : 'exclude'; - /** @type {(value?: string) => boolean} */ - const valid = (value) => { - const ok = isValidPattern(value); - if (!ok) warn(`ignoring invalid ${name} pattern: '${value}'`); - return ok; - }; - if (mode === 'arg' && input?.length) { - return splitOptionValue(input) - .filter((s) => s !== '') - .filter(valid); - } else if (mode === 'option' && Array.isArray(input)) { - return input.filter(valid); - } - return [...FILE_EXCLUDE_DEFAULT]; -} - -/** @type {(value?: string) => boolean} */ -const isValidPattern = (value) => { - return typeof value === 'string' && !/[\\\/\:]/.test(value); -}; - /** * @param {string | boolean | undefined} input * @param {ValidationContext & { optName: string; defaultValue: boolean; emptyValue: boolean }} context @@ -290,6 +107,19 @@ function validateBoolean(input, { warn, mode, optName, defaultValue, emptyValue return defaultValue; } +/** + * @param {string | boolean | undefined} input + * @param {ValidationContext} context + */ +export function validateCors(input, context) { + return validateBoolean(input, { + ...context, + optName: context.mode === 'arg' ? '--cors' : 'cors', + defaultValue: false, + emptyValue: true, + }); +} + /** * @param {undefined | string[]} input * @param {ValidationContext} context @@ -322,6 +152,42 @@ export function validateDirFile(input, { mode, warn }) { return [...DIR_FILE_DEFAULT]; } +/** + * @param {string | boolean | undefined} input + * @param {ValidationContext} context + */ +export function validateDirList(input, context) { + return validateBoolean(input, { + ...context, + optName: context.mode === 'arg' ? '--dir-list' : 'dirList', + defaultValue: true, + emptyValue: true, + }); +} + +/** + * @param {string[] | undefined} input + * @param {ValidationContext} context + * @returns {string[]} + */ +export function validateExclude(input, { mode, warn }) { + const name = mode === 'arg' ? '--exclude' : 'exclude'; + /** @type {(value?: string) => boolean} */ + const valid = (value) => { + const ok = isValidPattern(value); + if (!ok) warn(`ignoring invalid ${name} pattern: '${value}'`); + return ok; + }; + if (mode === 'arg' && input?.length) { + return splitOptionValue(input) + .filter((s) => s !== '') + .filter(valid); + } else if (mode === 'option' && Array.isArray(input)) { + return input.filter(valid); + } + return [...FILE_EXCLUDE_DEFAULT]; +} + /** * @param {undefined | string[]} input * @param {ValidationContext} context @@ -360,6 +226,54 @@ export function validateExt(input, { mode, warn }) { return [...EXTENSIONS_DEFAULT]; } +/** + * @param {string | boolean | undefined} input + * @param {ValidationContext} context + * @returns {boolean} + */ +export function validateGzip(input, context) { + return validateBoolean(input, { + ...context, + optName: context.mode === 'arg' ? '--gzip' : 'gzip', + defaultValue: true, + emptyValue: true, + }); +} + +/** + * @param {string[] | HttpHeaderRule[] | undefined} input + * @param {ValidationContext} context + * @returns {HttpHeaderRule[]} + */ +export function validateHeaders(input, context) { + const optName = context.mode === 'arg' ? '--header' : 'headers'; + const setError = (/** @type {any} */ rule) => + context.error(`invalid ${optName} value: ${JSON.stringify(rule)}`); + + if (context.mode === 'arg' && Array.isArray(input) && input.length > 0) { + return input + .map((item) => + typeof item === 'string' ? parseHeaders(item, { ...context, optName }) : undefined, + ) + .filter((item) => item != null) + .filter((item) => { + const ok = isValidHeaderRule(item); + if (!ok) setError(item); + return ok; + }); + } + + if (context.mode === 'option' && Array.isArray(input) && input.length > 0) { + return input.filter((item) => { + const ok = isValidHeaderRule(item); + if (!ok) setError(item); + return ok; + }); + } + + return []; +} + /** * @param {string | undefined} input * @param {ValidationContext} context @@ -476,6 +390,91 @@ export function validateRoot(input, { mode, error }) { return root; } +/** @type {(value: any) => value is HttpHeaderRule} */ +function isValidHeaderRule(value) { + const headerRegex = /^[A-Za-z0-9\-\_]+$/; + const include = value?.include; + const headers = value?.headers; + if (Array.isArray(include) && include.some((item) => typeof item !== 'string')) { + return false; + } + if (headers == null || typeof headers !== 'object') { + return false; + } + const entries = Object.entries(headers); + return ( + entries.length > 0 && + entries.every(([key, value]) => { + return ( + typeof key === 'string' && + headerRegex.test(key) && + typeof value === 'string' && + value.length > 0 + ); + }) + ); +} + +/** + * @param {string} input + * @param {ValidationContext & { optName: string }} context + * @returns {HttpHeaderRule | undefined} + */ +export function parseHeaders(input, { optName, warn }) { + input = input.trim(); + const colonPos = input.indexOf(':'); + const bracketPos = input.indexOf('{'); + + /** @type {(include?: string, entries?: string[][]) => HttpHeaderRule} */ + const makeRule = (include = '', entries = []) => { + const headers = Object.fromEntries(entries); + return include.length > 0 && include !== '*' + ? { headers, include: include.split(',').map((s) => s.trim()) } + : { headers }; + }; + + if (bracketPos >= 0 && colonPos > bracketPos && input.endsWith('}')) { + const jsonStart = input.indexOf('{'); + const include = input.slice(0, jsonStart).trim(); + const json = input.slice(jsonStart); + let obj; + try { + obj = JSON.parse(json); + } catch {} + if (obj != null && typeof obj === 'object') { + const valTypes = ['string', 'boolean', 'number']; + const entries = Object.entries(obj) + .map(([key, val]) => [ + typeof key === 'string' ? key : '', + valTypes.includes(typeof val) ? String(val) : '', + ]) + .filter((entry) => entry[0].length > 0 && entry[1].length > 0); + if (entries.length > 0) { + return makeRule(include, entries); + } + } else { + warn(`could not parse ${optName} value: '${json}'`); + } + } else if (colonPos > 0) { + const key = input.slice(0, colonPos).trim(); + const val = input.slice(colonPos + 1).trim(); + if (key && val) { + const header = key.split(/\s+/).at(-1) ?? key; + const include = header === key ? undefined : key.slice(0, key.indexOf(header)).trim(); + return makeRule(include, [[header, val]]); + } else { + warn(`could not parse ${optName} value: '${input}'`); + } + } else if (input) { + warn(`invalid ${optName} value: '${input}'`); + } +} + +/** @type {(value?: string) => boolean} */ +function isValidPattern(value) { + return typeof value === 'string' && !/[\\\/\:]/.test(value); +} + /** @type {(values: string[]) => string[]} */ function splitOptionValue(values) { const result = new Set(values.flatMap((s) => s.split(',')).map((s) => s.trim())); diff --git a/lib/server.js b/lib/server.js index 2119d8d..28cbb08 100644 --- a/lib/server.js +++ b/lib/server.js @@ -1,24 +1,31 @@ import { Buffer } from 'node:buffer'; import { createServer } from 'node:http'; +import { createGzip, gzipSync } from 'node:zlib'; -import { SUPPORTED_METHODS } from './constants.js'; +import { MAX_COMPRESS_SIZE, SUPPORTED_METHODS } from './constants.js'; import { getContentType, typeForFilePath } from './content-type.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'; +import { headerCase } 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('./content-type.js').TypeResult} TypeResult @typedef {import('./types.js').DirIndexItem} DirIndexItem @typedef {import('./types.js').FSEntryKind} FSEntryKind @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 {{ + body?: string | Buffer | import('node:fs').ReadStream; + isText?: boolean; + statSize?: number; +}} SendPayload **/ /** @@ -66,7 +73,7 @@ export class RequestHandler { /** * @param {{ req: IncomingMessage, res: ServerResponse }} reqRes * @param {FileResolver} resolver - * @param {ServerOptions & {_dryRun?: boolean}} options + * @param {ServerOptions & {_noStream?: boolean}} options */ constructor({ req, res }, resolver, options) { this.#req = req; @@ -138,16 +145,17 @@ export class RequestHandler { async #sendFile(file) { /** @type {FileHandle | undefined} */ let handle; - /** @type {string | undefined} */ - let contentType; /** @type {number | undefined} */ - let contentLength; + let statSize; + /** @type {TypeResult | undefined} */ + let contentType; + try { // check that we can actually open the file // (especially on windows where it might be busy) handle = await this.#resolver.open(file.filePath); + statSize = (await handle.stat()).size; contentType = await getContentType({ filePath: file.filePath, fileHandle: handle }); - contentLength = (await handle.stat()).size; } catch (/** @type {any} */ err) { if (err?.syscall === 'open' && err.code === 'EBUSY') { this.status = err?.syscall === 'open' && err.code === 'EBUSY' ? 403 : 500; @@ -163,132 +171,177 @@ export class RequestHandler { return this.#sendErrorPage(); } - if (this.method === 'OPTIONS') { - this.status = 204; - } - this.#setHeaders(file.localPath ?? file.filePath, { - contentType, - contentLength, + contentType: contentType?.toString(), cors: this.#options.cors, headers: this.#options.headers, }); - if (this.#options._dryRun) { - return; - } else if (this.method === 'OPTIONS' || this.method === 'HEAD') { - return this.#send(); - } else { - return this.#send(this.#resolver.readStream(file.filePath)); + /** @type {SendPayload} */ + const data = { isText: contentType?.group === 'text', statSize }; + + if (this.method === 'OPTIONS') { + this.status = 204; + } + // read file as stream + else if (this.method !== 'HEAD' && !this.#options._noStream) { + data.body = this.#resolver.readStream(file.filePath); } + + return this.#send(data); } /** * @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); + + if (this.method === 'OPTIONS') { + this.status = 204; + return this.#send(); + } + + const items = await this.#resolver.index(dir.filePath); + return this.#send({ + body: await dirListPage({ urlPath: this.urlPath, file: dir, items }, this.#options), + isText: true, + }); } 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: [], }); - return this.#send(body); + + if (this.method === 'OPTIONS') { + return this.#send(); + } + + return this.#send({ + body: await errorPage({ status: this.status, urlPath: this.urlPath }), + isText: true, + }); } /** - * @param {string | import('node:buffer').Buffer | import('node:fs').ReadStream} [contents] + * @param {SendPayload} [payload] */ - #send(contents) { - if (this.method === 'HEAD' || this.method === 'OPTIONS') { + #send({ body, isText = false, statSize } = {}) { + // stop early if possible + if (this.#req.destroyed) { + this.#res.end(); + return; + } else if (this.method === 'OPTIONS') { + this.#header('content-length', '0'); this.#res.end(); - } else if (this.#req.destroyed) { - this.#setHeader('content-length', '0'); + return; + } + + const isHead = this.method === 'HEAD'; + const compress = + this.#options.gzip && + canCompress({ accept: this.#req.headers['accept-encoding'], isText, statSize }); + + // Send file contents if already available + if (typeof body === 'string' || Buffer.isBuffer(body)) { + const buf = compress ? gzipSync(body) : Buffer.from(body); + this.#header('content-length', buf.byteLength); + if (compress) { + this.#header('content-encoding', 'gzip'); + } + if (!isHead) { + this.#res.write(buf); + } this.#res.end(); - } else if (typeof contents === 'string' || Buffer.isBuffer(contents)) { - this.#res.write(contents); + return; + } + + // No content-length when compressing: we can't use the stat size, + // and compressing all at once would defeat streaming and/or run out of memory + if (typeof statSize === 'number' && !compress) { + this.#header('content-length', String(statSize)); + } + + if (isHead || body == null) { this.#res.end(); - } else if (typeof contents?.pipe === 'function') { - contents.pipe(this.#res); + return; + } + + // Send file stream + if (compress) { + this.#header('content-encoding', 'gzip'); + body.pipe(createGzip()).pipe(this.#res); + } else { + body.pipe(this.#res); } } /** * @param {string} name - * @param {number | string | string[]} value + * @param {null | number | string | string[]} value + * @param {boolean} [normalizeCase] */ - #setHeader(name, value) { + #header(name, value, normalizeCase = true) { if (this.#res.headersSent) return; - this.#res.setHeader(headerCase(name), value); + if (normalizeCase) name = headerCase(name); + if (typeof value === 'number') value = String(value); + if (value === null) { + this.#res.removeHeader(name); + } else { + this.#res.setHeader(name, value); + } } /** + * Set all response headers, except for content-length * @param {string} localPath - * @param {Partial<{ contentType: string, contentLength: number; cors: boolean; headers: ServerOptions['headers'] }>} options + * @param {Partial<{ contentType: string, cors: boolean; headers: ServerOptions['headers'] }>} options */ - #setHeaders(localPath, { contentLength, contentType, cors, headers }) { + #setHeaders(localPath, { contentType, cors, headers }) { if (this.#res.headersSent) return; const isOptions = this.method === 'OPTIONS'; const headerRules = headers ?? this.#options.headers; if (isOptions || this.status === 405) { - this.#setHeader('allow', SUPPORTED_METHODS.join(', ')); + this.#header('allow', SUPPORTED_METHODS.join(', ')); } + if (!isOptions) { contentType ??= typeForFilePath(localPath).toString(); - this.#setHeader('content-type', contentType); - } - if (isOptions || this.status === 204) { - contentLength = 0; - } - if (typeof contentLength === 'number') { - this.#setHeader('content-length', String(contentLength)); + this.#header('content-type', contentType); } + if (cors ?? this.#options.cors) { this.#setCorsHeaders(); } + if (localPath && headerRules.length) { + const blockList = ['content-encoding', 'content-length']; for (const { name, value } of fileHeaders(localPath, headerRules)) { - this.#res.setHeader(name, value); + if (!blockList.includes(name.toLowerCase())) { + this.#header(name, value, false); + } } } } #setCorsHeaders() { const origin = this.#req.headers['origin']; - if (typeof origin === 'string') { - this.#setHeader('access-control-allow-origin', origin); - } - + if (!origin) return; + this.#header('access-control-allow-origin', origin); if (isPreflight(this.#req)) { - this.#setHeader('access-control-allow-methods', SUPPORTED_METHODS.join(', ')); + this.#header('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.#header('access-control-allow-headers', allowHeaders.join(', ')); } - this.#setHeader('access-control-max-age', '60'); + this.#header('access-control-max-age', '60'); } } @@ -299,11 +352,27 @@ export class RequestHandler { } } +/** + * @param {{ accept?: string | string[]; isText?: boolean; statSize?: number }} data + * @returns {boolean} + */ +function canCompress({ accept = '', statSize = 0, isText = false }) { + accept = Array.isArray(accept) ? accept.join(',') : accept; + if (isText && statSize <= MAX_COMPRESS_SIZE && accept) { + return accept + .toLowerCase() + .split(',') + .some((value) => value.split(';')[0].trim() === 'gzip'); + } + return false; +} + /** * @param {string} localPath * @param {ServerOptions['headers']} rules + * @param {string[]} [blockList] */ -export function fileHeaders(localPath, rules) { +export function fileHeaders(localPath, rules, blockList = []) { /** @type {Array<{name: string; value: string}>} */ const headers = []; for (const rule of rules) { @@ -312,6 +381,7 @@ export function fileHeaders(localPath, rules) { if (!matcher.test(localPath)) continue; } for (const [name, value] of Object.entries(rule.headers)) { + if (blockList.length && blockList.includes(name.toLowerCase())) continue; headers.push({ name, value }); } } diff --git a/lib/types.js b/lib/types.js index ecad47a..b386ded 100644 --- a/lib/types.js +++ b/lib/types.js @@ -1,6 +1,6 @@ /** -@typedef {'cors' | 'dirFile' | 'dirList' | 'exclude' | 'ext' | 'header' | 'help' | 'host' | 'port' | 'version'} OptionName +@typedef {'cors' | 'dirFile' | 'dirList' | 'exclude' | 'ext' | 'gzip' | 'header' | 'help' | 'host' | 'port' | 'version'} OptionName @typedef {{ args: string[]; help: string; argDefault?: string }} OptionSpec @typedef {{ @@ -38,8 +38,9 @@ dirFile: string[]; dirList: boolean, exclude: string[]; - cors: boolean; headers: HttpHeaderRule[]; + cors: boolean; + gzip: boolean; }} ServerOptions @typedef {{ diff --git a/lib/utils.js b/lib/utils.js index 4d4e32a..66f92dc 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -104,14 +104,6 @@ export function intRange(start, end, limit) { .map((_, i) => start + i * sign); } -/** - * @param {string} input - * @returns {number} - */ -export function strBytes(input) { - return new TextEncoder().encode(input).byteLength; -} - /** * @type {(input: string, options?: { start?: boolean; end?: boolean }) => string} */ diff --git a/test/options.test.js b/test/options.test.js index 64fb61e..fc3a2fe 100644 --- a/test/options.test.js +++ b/test/options.test.js @@ -11,6 +11,7 @@ import { validateDirList, validateExclude, validateExt, + validateGzip, validateHeaders, validateHost, validatePorts, @@ -53,7 +54,9 @@ suite('serverOptions', () => { }); test('default options (arg mode)', () => { - const { errors, options } = serverOptions({}, argify('')); + const { errors, options } = serverOptions({ + args: argify(''), + }); strictEqual(options.root, cwd()); match(options.host, hostWildcardPattern); deepStrictEqual(options.ports, defaultPorts); @@ -276,6 +279,35 @@ suite('validateExt', () => { }); }); +suite('validateGzip', () => { + test('default is true', () => { + const context = validationContext('arg'); + strictEqual(validateGzip(undefined, context), true); + strictEqual(validateGzip('', context), true); + deepStrictEqual(context.errors, []); + }); + + test('parses boolean-like arg values', () => { + const context = validationContext('arg'); + strictEqual(validateGzip('', context), true); + strictEqual(validateGzip('true', context), true); + strictEqual(validateGzip('1', context), true); + strictEqual(validateGzip('false', context), false); + strictEqual(validateGzip('0', context), false); + deepStrictEqual(context.errors, []); + }); + + test('rejects invalid strings', () => { + const context = validationContext('arg'); + validateGzip('aye', context); + validateGzip('NOPE', context); + deepStrictEqual(context.errors, [ + { warn: `invalid --gzip value: 'aye'` }, + { warn: `invalid --gzip value: 'NOPE'` }, + ]); + }); +}); + suite('validateHeaders', () => { /** @type {(context: ValidationContext) => (input: string, expected: HttpHeaderRule) => void} */ const getCheckHeaders = (context) => { diff --git a/test/server.test.js b/test/server.test.js index 8997996..e4b4f8c 100644 --- a/test/server.test.js +++ b/test/server.test.js @@ -60,7 +60,7 @@ function mockReqRes(method, url, headers = {}) { */ function withHandlerContext(options, files) { const resolver = getResolver(options, files); - const handlerOptions = { ...options, _dryRun: true }; + const handlerOptions = { ...options, gzip: false, _noStream: true }; return (method, url, headers) => { const { req, res } = mockReqRes(method, url, headers); @@ -70,10 +70,11 @@ function withHandlerContext(options, files) { /** * @param {HttpHeaderRule[]} rules + * @param {string[]} [blockList] * @returns {(filePath: string) => Array<{name: string; value: string}>} */ -function withHeaderRules(rules) { - return (filePath) => fileHeaders(filePath, rules); +function withHeaderRules(rules, blockList) { + return (filePath) => fileHeaders(filePath, rules, blockList); } suite('fileHeaders', () => { @@ -95,6 +96,21 @@ suite('fileHeaders', () => { deepStrictEqual(headers('any/thing.ext'), expected); }); + test('headers matching blocklist are rejected', () => { + const headers = withHeaderRules( + [ + { headers: { 'X-Header1': 'one', 'Content-Length': '1000' } }, + { include: ['*.*'], headers: { 'X-Header2': 'two', 'Content-Encoding': 'br' } }, + ], + ['content-length', 'content-encoding'], + ); + deepStrictEqual(headers(''), [{ name: 'X-Header1', value: 'one' }]); + deepStrictEqual(headers('readme.md'), [ + { name: 'X-Header1', value: 'one' }, + { name: 'X-Header2', value: 'two' }, + ]); + }); + test('custom headers with pattern are added matching files only', () => { const headers = withHeaderRules([ { include: ['path'], headers: { 'x-header1': 'true' } }, @@ -124,18 +140,7 @@ suite('staticServer', () => { }); }); -suite('RequestHandler.constructor', () => { - test('starts with a 200 status', async () => { - const options = { ...blankOptions, _dryRun: true }; - const handler = new RequestHandler(mockReqRes('GET', '/'), getResolver(), options); - strictEqual(handler.method, 'GET'); - strictEqual(handler.urlPath, '/'); - strictEqual(handler.status, 200); - strictEqual(handler.file, null); - }); -}); - -suite('RequestHandler.process', async () => { +suite('RequestHandler', async () => { const test_files = { '.gitignore': '*.html\n', 'index.html': '

Hello World

', @@ -149,10 +154,16 @@ suite('RequestHandler.process', async () => { '.well-known/security.txt': '# hello', '.well-known/something-else.json': '{"data":{}}', }; - - const request0 = withHandlerContext(blankOptions, test_files); const request = withHandlerContext(defaultOptions, test_files); + test('starts with a 200 status', async () => { + const handler = withHandlerContext(blankOptions, {})('GET', '/'); + strictEqual(handler.method, 'GET'); + strictEqual(handler.urlPath, '/'); + strictEqual(handler.status, 200); + strictEqual(handler.file, null); + }); + for (const method of ['PUT', 'DELETE']) { test(`${method} method is unsupported`, async () => { const handler = request(method, '/README.md'); @@ -352,15 +363,7 @@ suite('RequestHandler.process', async () => { Origin: 'https://example.com', 'Access-Control-Request-Method': 'GET', }); - const preflightReq = request('OPTIONS', '/manifest.json', { - Origin: 'https://example.com', - 'Access-Control-Request-Method': 'POST', - 'Access-Control-Request-Headers': 'X-Header1', - }); - await getReq.process(); - await preflightReq.process(); - strictEqual(getReq.status, 200); checkHeaders(getReq.headers, { 'access-control-allow-origin': 'https://example.com', @@ -368,6 +371,14 @@ suite('RequestHandler.process', async () => { 'content-length': '18', }); + return; + + const preflightReq = request('OPTIONS', '/manifest.json', { + Origin: 'https://example.com', + 'Access-Control-Request-Method': 'POST', + 'Access-Control-Request-Headers': 'X-Header1', + }); + await preflightReq.process(); strictEqual(preflightReq.status, 204); checkHeaders(preflightReq.headers, { allow: allowMethods, diff --git a/test/shared.js b/test/shared.js index d0b9bab..0d6964e 100644 --- a/test/shared.js +++ b/test/shared.js @@ -26,6 +26,7 @@ export const blankOptions = { exclude: [], cors: false, headers: [], + gzip: false, }; /** @type {ServerOptions} */ @@ -37,6 +38,7 @@ export const defaultOptions = { exclude: [...FILE_EXCLUDE_DEFAULT], cors: false, headers: [], + gzip: true, }; /** diff --git a/test/utils.test.js b/test/utils.test.js index cc93b3a..31f1cb8 100644 --- a/test/utils.test.js +++ b/test/utils.test.js @@ -10,7 +10,6 @@ import { headerCase, intRange, isPrivateIPv4, - strBytes, trimSlash, withResolvers, } from '../lib/utils.js'; @@ -188,16 +187,6 @@ suite('intRange', () => { }); }); -suite('strBytes', () => { - test('returns the UTF-8 byte length of a string', () => { - strictEqual(strBytes(''), 0); - strictEqual(strBytes('hello'), 5); - strictEqual(strBytes('Ça alors‽'), 12); - strictEqual(strBytes('👩🏽'), 8); - strictEqual(strBytes('😭'.repeat(100_000)), 400_000); - }); -}); - suite('trimSlash', () => { test('trims start and end slashes by default', () => { strictEqual(trimSlash('/hello/'), 'hello');