tiny-server-essentials
Version:
A good utility toolkit to unify Express v5 and Socket.IO v4 into a seamless development experience with modular helpers, server wrappers, and WebSocket tools.
895 lines (894 loc) • 39.3 kB
JavaScript
import { createReadStream, existsSync } from 'fs';
import { stat } from 'fs/promises';
import { Readable as ReadableStream } from 'stream';
import { createHash, randomBytes } from 'crypto';
import { createServer, Server as HttpServer } from 'http';
import { Server as HttpsServer } from 'https';
import express from 'express';
import createHttpError from 'http-errors';
import TinyWebInstance from './TinyWebInstance.mjs';
import { extractIpList } from './Utils.mjs';
/** @typedef {import('express').Request} Request */
/**
* @typedef {number} HttpStatusCode
*/
/**
* @typedef {(req: Request) => string[]} IPExtractor
*/
/**
* @typedef {(req: Request) => boolean} DomainValidator
*/
/**
* Represents the structured data extracted from an HTTP Origin header.
*
* @typedef {Object} OriginData
* @property {string|null} raw - The raw Origin header value.
* @property {string} [protocol] - The protocol used (e.g., 'http' or 'https').
* @property {string} [hostname] - The hostname extracted from the origin.
* @property {string} [port] - The port number (explicit or default based on protocol).
* @property {string} [full] - The full reconstructed URL from the origin.
* @property {string} [error] - Any parsing error encountered while processing the origin.
*/
/**
* TinyExpress provides a simple wrapper and factory for Express v5,
* offering utilities and enhanced middleware management.
*
* This class is ideal for quickly spinning up an Express app with
* strongly-typed middleware and error-handling support.
*
* @class
*/
class TinyExpress {
/** @typedef {import('express').Application} ExpressApp */
/** @typedef {import('express').Response} Response */
/** @typedef {import('express').NextFunction} NextFunction */
/** @typedef {import('http-errors').HttpError} HttpError */
/** @typedef {import('express').RequestHandler} RequestHandler */
/** @typedef {import('express').ErrorRequestHandler} ErrorRequestHandler */
#domainTypeChecker = 'hostname';
#forbiddenDomainMessage = 'Forbidden domain.';
/** @type {{ [key: string]: IPExtractor }} */
#ipExtractors = {};
/** @type {string} */
#activeExtractor = 'DEFAULT';
/**
* @type {{
* refreshCookieName: string;
* cookieName: string;
* headerName: string;
* errMessage: string;
* enabled: boolean;
* refreshInterval: number|null
* }}
* */
#csrf = {
refreshCookieName: '_csrfRefresh',
cookieName: '_csrf',
headerName: 'X-Csrf-Token',
errMessage: 'Invalid or missing CSRF token',
enabled: false,
refreshInterval: null,
};
/**
* A list of domain validators using different request properties.
*
* Each property in this object represents a header or request property to check the domain against.
* Each associated array contains one or more callback functions that validate or extract the domain.
*
* @type {{
* [key: string]: DomainValidator
* }}
*/
#domainValidators = {};
/**
* A list of standard HTTP status codes, their default messages,
* and some common non-official or less standard status codes.
* Follows the format { [statusCode]: message }.
*
* @readonly
* @type {Object<number|string, string>}
*/
#httpCodes = {
// Informational
100: 'Continue',
101: 'Switching Protocols',
102: 'Processing', // WebDAV
103: 'Early Hints', // RFC 8297
// Successful
200: 'OK',
201: 'Created',
202: 'Accepted',
203: 'Non-Authoritative Information',
204: 'No Content',
205: 'Reset Content',
206: 'Partial Content',
207: 'Multi-Status', // WebDAV
208: 'Already Reported', // WebDAV
226: 'IM Used', // HTTP Delta encoding
// Redirection
300: 'Multiple Choices',
301: 'Moved Permanently',
302: 'Found',
303: 'See Other',
304: 'Not Modified',
305: 'Use Proxy',
306: 'Unused',
307: 'Temporary Redirect',
308: 'Permanent Redirect', // RFC 7538
// Client Error
400: 'Bad Request',
401: 'Unauthorized',
402: 'Payment Required',
403: 'Forbidden',
404: 'Not Found',
405: 'Method Not Allowed',
406: 'Not Acceptable',
407: 'Proxy Authentication Required',
408: 'Request Timeout',
409: 'Conflict',
410: 'Gone',
411: 'Length Required',
412: 'Precondition Failed',
413: 'Request Entity Too Large',
414: 'Request-URI Too Long',
415: 'Unsupported Media Type',
416: 'Requested Range Not Satisfiable',
417: 'Expectation Failed',
418: "I'm a teapot", // RFC 2324 (April Fools)
421: 'Misdirected Request', // RFC 7540
422: 'Unprocessable Entity', // WebDAV
423: 'Locked', // WebDAV
424: 'Failed Dependency', // WebDAV
425: 'Too Early', // RFC 8470
426: 'Upgrade Required',
428: 'Precondition Required', // RFC 6585
429: 'Too Many Requests', // RFC 6585
431: 'Request Header Fields Too Large', // RFC 6585
451: 'Unavailable For Legal Reasons', // RFC 7725
// Server Error
500: 'Internal Server Error',
501: 'Not Implemented',
502: 'Bad Gateway',
503: 'Service Unavailable',
504: 'Gateway Timeout',
505: 'HTTP Version Not Supported',
506: 'Variant Also Negotiates', // RFC 2295
507: 'Insufficient Storage', // WebDAV
508: 'Loop Detected', // WebDAV
510: 'Not Extended', // RFC 2774
511: 'Network Authentication Required', // RFC 6585
// Common but unofficial
520: 'Web Server Returned an Unknown Error', // Cloudflare
521: 'Web Server Is Down', // Cloudflare
522: 'Connection Timed Out', // Cloudflare
523: 'Origin Is Unreachable', // Cloudflare
524: 'A Timeout Occurred', // Cloudflare
525: 'SSL Handshake Failed', // Cloudflare
526: 'Invalid SSL Certificate', // Cloudflare
};
/**
* Creates and initializes a new TinyExpress instance.
*
* This wrapper extends the base Express application with support for domain-based request validation,
* automatic middleware injection, and customizable validator logic.
*
* When instantiated, the following behaviors are applied:
* - Injects a middleware to validate incoming requests using the active domain validator (based on `#domainTypeChecker`).
* If the validator does not exist or the domain is invalid, a 403 Forbidden error is triggered.
* - Automatically registers the default loopback domains: `localhost`, `127.0.0.1`, and `::1`.
* - Registers default domain validators for `x-forwarded-host`, `hostname`, and `host` headers.
*
* @constructor
* @param {ExpressApp} [app=express()] - An optional existing Express application instance.
* If not provided, a new instance is created using `express()`.
*/
constructor(app = express()) {
this.root = app;
this.root.use(
/** @type {RequestHandler} */
(req, res, next) => {
const type = this.#domainTypeChecker;
let valid = false;
if (type === 'ALL') {
const validators = Object.values(this.#domainValidators);
for (const validator of validators) {
if (typeof validator !== 'function')
throw new Error(`Domain validator "${type}" is not registered.`);
valid = validator(req);
if (valid)
break;
}
}
else {
const validator = this.#domainValidators[type];
if (typeof validator !== 'function')
throw new Error(`Domain validator "${type}" is not registered.`);
valid = validator(req);
}
if (!valid) {
const err = createHttpError(403, this.#forbiddenDomainMessage);
next(err);
return;
}
next();
});
}
/**
* Modifies a CSRF config option, only if the key exists and value is a string.
* @param {string} key - Either "cookieName", "headerName", or "errMessage".
* @param {string} value - The new value to assign.
*/
setCsrfOption(key, value) {
if (typeof key !== 'string')
throw new Error(`Expected 'key' to be a string, got ${typeof key}`);
if (key === 'enabled' || key === 'refreshInterval' || !Object.hasOwn(this.#csrf, key))
throw new Error(`Invalid config key '${key}'. Allowed keys: ${Object.keys(this.#csrf)
.filter((k) => !['enabled', 'refreshInterval'].includes(k))
.join(', ')}`);
if (typeof value !== 'string' || value.trim() === '')
throw new Error(`Value for '${key}' must be a non-empty string`);
// @ts-ignore
this.#csrf[key] = value;
}
/**
* Sets the refresh interval in milliseconds for CSRF tokens.
* @param {number|null} ms - Must be a positive number or null to disable.
*/
setCsrfRefreshInterval(ms) {
if (ms !== null && (!Number.isInteger(ms) || ms <= 0))
throw new Error('refreshInterval must be a positive integer or null');
this.#csrf.refreshInterval = ms;
}
/**
* Returns a shallow copy of the current CSRF config.
* @returns {{
* refreshCookieName: string;
* cookieName: string;
* headerName: string;
* errMessage: string;
* enabled: boolean;
* refreshInterval: number|null
* }}
*/
geCsrftOptions() {
return { ...this.#csrf };
}
/**
* Middleware to set a CSRF token cookie if it's not already set or expired.
* @param {number} [bytes=24] - Number of bytes to generate for the CSRF token.
* @param {{
* httpOnly?: boolean,
* sameSite?: 'lax' | 'strict' | 'none',
* secure?: boolean
* }} [options={}]
*/
installCsrfToken(bytes = 24, { httpOnly = false, sameSite = 'lax', secure = false } = {}) {
if (this.#csrf.enabled)
throw new Error('CSRF protection has already been enabled');
if (!Number.isInteger(bytes) || bytes <= 0)
throw new TypeError('bytes must be a positive integer');
if (typeof httpOnly !== 'boolean')
throw new TypeError('httpOnly must be a boolean');
if (!['lax', 'strict', 'none'].includes(sameSite))
throw new TypeError("sameSite must be one of 'lax', 'strict', or 'none'");
if (typeof secure !== 'boolean')
throw new TypeError('secure must be a boolean');
this.#csrf.enabled = true;
const refresh = this.#csrf.refreshInterval;
this.root.use((req, res, next) => {
let token = req.cookies?.[this.#csrf.cookieName];
const csrfIssuedAt = Number(req.cookies?.[this.#csrf.refreshCookieName]);
const now = Date.now();
if (!token ||
(refresh &&
(!Number.isFinite(csrfIssuedAt) ||
Number.isNaN(csrfIssuedAt) ||
!csrfIssuedAt ||
now - csrfIssuedAt > refresh))) {
token = randomBytes(bytes).toString('hex');
res.cookie(this.#csrf.cookieName, token, {
httpOnly,
sameSite,
secure,
});
req.cookies[this.#csrf.cookieName] = token;
if (refresh) {
res.cookie(this.#csrf.refreshCookieName, String(now), {
httpOnly,
sameSite,
secure,
});
req.cookies[this.#csrf.refreshCookieName] = String(now);
}
}
next();
});
}
/**
* Middleware to verify that the request contains a valid CSRF token in the header.
* @returns {RequestHandler}
*/
verifyCsrfToken() {
return (req, res, next) => {
const tokenFromCookie = req.cookies?.[this.#csrf.cookieName];
const tokenFromHeader = req.get(this.#csrf.headerName);
if (!tokenFromCookie || !tokenFromHeader || tokenFromCookie !== tokenFromHeader) {
const err = createHttpError(403, this.#csrf.errMessage);
next(err);
return;
}
next();
};
}
/**
* Returns the current TinyWeb instance.
*
* @returns {TinyWebInstance} The active Http server.
*/
getWeb() {
if (!(this.web instanceof TinyWebInstance))
throw new Error('this.web expects an instance of TinyWebInstance.');
return this.web;
}
/**
* Returns the current Http server instance.
*
* @returns {HttpServer | HttpsServer} The active Http server.
*/
getServer() {
return this.getWeb().getServer();
}
/**
* Returns the Express app instance.
*
* @returns {import('express').Application} Express application instance.
*/
getRoot() {
return this.root;
}
/**
* Init instance.
*
* @param {TinyWebInstance|HttpServer|HttpsServer} [web=new TinyWebInstance()] - An instance of TinyWebInstance.
*
* @throws {Error} If `web` is not an instance of TinyWebInstance.
*/
init(web = new TinyWebInstance(createServer(this.root))) {
if (!(web instanceof TinyWebInstance) &&
// @ts-ignore
!(web instanceof HttpServer) &&
// @ts-ignore
!(web instanceof HttpsServer))
throw new Error('init expects an instance of TinyWebInstance, HttpServer, or HttpsServer.');
/** @type {TinyWebInstance} */ this.web;
if (web instanceof TinyWebInstance)
this.web = web;
else
this.web = new TinyWebInstance(web);
if (!this.web.hasDomain('localhost'))
this.web.addDomain('localhost');
if (!this.web.hasDomain('127.0.0.1'))
this.web.addDomain('127.0.0.1');
if (!this.web.hasDomain('::1'))
this.web.addDomain('::1');
this.addDomainValidator('x-forwarded-host', (req) => typeof req.headers['x-forwarded-host'] === 'string'
? this.web.canDomain(req.headers['x-forwarded-host'])
: false);
this.addDomainValidator('hostname', (req) => typeof req.hostname === 'string' ? this.web.canDomain(req.hostname) : false);
this.addDomainValidator('headerHost', (req) => typeof req.headers.host === 'string' ? this.web.canDomain(req.headers.host) : false);
this.addIpExtractor('ip', (req) => extractIpList(req.ip));
this.addIpExtractor('ips', (req) => extractIpList(req.ips));
this.addIpExtractor('remoteAddress', (req) => extractIpList(req.socket?.remoteAddress));
this.addIpExtractor('x-forwarded-for', (req) => extractIpList(req.headers['x-forwarded-for']));
this.addIpExtractor('fastly-client-ip', (req) => extractIpList(req.headers['fastly-client-ip']));
}
/**
* Parses the Origin header from an Express request and extracts structured information.
*
* @param {Request} req - The Express request object.
* @returns {OriginData} An object containing detailed parts of the origin header.
*/
getOrigin(req) {
const raw = req.get('Origin');
/** @type {OriginData} */
const originInfo = {
raw: raw || null,
};
try {
if (raw) {
const url = new URL(raw);
originInfo.protocol = url.protocol.replace(':', '');
originInfo.hostname = url.hostname;
originInfo.port = url.port || (url.protocol === 'https:' ? '443' : '80');
originInfo.full = url.href;
}
}
catch (err) {
originInfo.error = 'Invalid Origin header';
}
return originInfo;
}
/**
* Registers a new IP extractor under a specific key.
*
* Each extractor must be a function that receives an Express `Request` and returns a string (IP) or null.
* The key `"DEFAULT"` is reserved and cannot be used directly.
*
* @param {string} key
* @param {IPExtractor} callback
* @throws {Error} If the key is invalid or already registered.
*/
addIpExtractor(key, callback) {
if (typeof key !== 'string')
throw new Error('Extractor key must be a string.');
if (key === 'DEFAULT')
throw new Error('"DEFAULT" is a reserved keyword.');
if (typeof callback !== 'function')
throw new Error('Extractor must be a function.');
if (this.#ipExtractors[key])
throw new Error(`Extractor "${key}" already exists.`);
this.#ipExtractors[key] = callback;
}
/**
* Removes a registered IP extractor.
*
* Cannot remove the extractor currently in use unless it's set to "DEFAULT".
*
* @param {string} key
* @throws {Error} If the key is invalid or in use.
*/
removeIpExtractor(key) {
if (typeof key !== 'string')
throw new Error('Extractor key must be a string.');
if (!(key in this.#ipExtractors))
throw new Error(`Extractor "${key}" not found.`);
if (this.#activeExtractor === key)
throw new Error(`Cannot remove extractor "${key}" because it is currently in use.`);
delete this.#ipExtractors[key];
}
/**
* Returns a shallow clone of the current extractors.
*
* @returns {{ [key: string]: IPExtractor }}
*/
getIpExtractors() {
return { ...this.#ipExtractors };
}
/**
* Sets the currently active extractor key.
*
* @param {string} key
* @throws {Error} If the key is not found.
*/
setActiveIpExtractor(key) {
if (key !== 'DEFAULT' && !(key in this.#ipExtractors))
throw new Error(`Extractor "${key}" not found.`);
this.#activeExtractor = key;
}
/**
* Returns the current extractor function based on the active key.
*
* @returns {IPExtractor}
*/
getActiveExtractor() {
if (this.#activeExtractor === 'DEFAULT') {
// Fallback behavior (standard Express logic)
return (req) => {
const ips = [
...extractIpList(req.ip),
...extractIpList(req.ips),
...extractIpList(req.socket?.remoteAddress),
];
const uniqueIps = [...new Set(ips)];
return uniqueIps;
};
}
return this.#ipExtractors[this.#activeExtractor];
}
/**
* Extracts the IP address from a request using the active extractor.
* @param {Request} req
* @returns {string[]}
*/
extractIp(req) {
return this.getActiveExtractor()(req);
}
/**
* Registers a new domain validator under a specific key.
*
* Each validator must be a function that receives an Express `Request` object and returns a boolean.
* The key `"ALL"` is reserved and cannot be used as a validator key.
*
* @param {string} key - The key name identifying the validator (e.g., 'host', 'x-forwarded-host').
* @param {DomainValidator} callback - The validation function to be added.
* @throws {Error} If the key is not a string.
* @throws {Error} If the key is "ALL".
* @throws {Error} If the callback is not a function.
* @throws {Error} If a validator with the same key already exists.
*/
addDomainValidator(key, callback) {
if (typeof key !== 'string')
throw new Error('Validator key must be a string.');
if (key === 'ALL')
throw new Error('"ALL" is a reserved keyword and cannot be used as a validator key.');
if (typeof callback !== 'function')
throw new Error('Validator callback must be a function.');
if (this.#domainValidators[key])
throw new Error(`Validator with key "${key}" already exists.`);
this.#domainValidators[key] = callback;
}
/**
* Removes a registered domain validator by its key.
*
* Cannot remove the validator currently being used by the domain type checker,
* unless the type checker is set to "ALL".
*
* @param {string} key - The key name of the validator to remove.
* @throws {Error} If the key is not a string.
* @throws {Error} If no validator is found under the given key.
* @throws {Error} If the validator is currently in use by the domain type checker.
*/
removeDomainValidator(key) {
if (typeof key !== 'string')
throw new Error('Validator key must be a string.');
if (!(key in this.#domainValidators))
throw new Error(`Validator with key "${key}" not found.`);
if (this.#domainTypeChecker === key)
throw new Error(`Cannot remove validator "${key}" because it is currently in use.`);
delete this.#domainValidators[key];
}
/**
* Returns a shallow clone of the current domain validators map.
*
* The returned object maps each key to its corresponding validation function.
*
* @returns {{ [key: string]: DomainValidator }} A cloned object of the validators.
*/
getDomainValidators() {
return { ...this.#domainValidators };
}
/**
* Sets the current domain validation strategy by key name.
*
* The provided key must exist in the registered domain validators,
* or be the string `"ALL"` to enable all validators simultaneously (not recommended).
*
* @param {string} key - The key of the validator to use (e.g., 'hostname', 'x-forwarded-host'), or 'ALL'.
* @throws {Error} If the key is not a string.
* @throws {Error} If the key is not found among the validators and is not 'ALL'.
*/
setDomainTypeChecker(key) {
if (typeof key !== 'string')
throw new Error('Domain type checker must be a string.');
if (key !== 'ALL' && !(key in this.#domainValidators))
throw new Error(`Validator key "${key}" is not registered.`);
this.#domainTypeChecker = key;
}
/**
* Retrieves the HTTP status message for a given status code.
*
* @param {number|string} code - The HTTP status code to look up.
* @returns {string} The corresponding HTTP status message.
* @throws {Error} If the status code is not found.
*/
getHttpStatusMessage(code) {
const message = this.#httpCodes[code];
if (typeof message !== 'string')
throw new Error(`HTTP status code "${code}" is not registered.`);
return message;
}
/**
* Checks whether a given HTTP status code is registered.
*
* @param {number|string} code - The HTTP status code to check.
* @returns {boolean} `true` if the status code exists, otherwise `false`.
*/
hasHttpStatusMessage(code) {
return typeof this.#httpCodes[code] === 'string';
}
/**
* Adds a new HTTP status code and message to the list.
* Does not allow overwriting existing codes.
*
* @param {number|string} code - The HTTP status code to add.
* @param {string} message - The message associated with the code.
* @throws {Error} If the code already exists.
* @throws {Error} If the code is not a number or string.
* @throws {Error} If the message is not a string.
*/
addHttpCode(code, message) {
if (typeof code !== 'number' && typeof code !== 'string')
throw new Error('HTTP status code must be a number or string.');
if (typeof message !== 'string')
throw new Error('HTTP status message must be a string.');
if (this.#httpCodes.hasOwnProperty(code))
throw new Error(`HTTP status code "${code}" already exists.`);
this.#httpCodes[code] = message;
}
/**
* Sends an HTTP response with the given status code.
* If a callback is provided, it will be called instead of sending an empty response.
*
* @param {import('express').Response} res - Express response object.
* @param {number} code - HTTP status code to send.
*
* @returns {import('express').Response} The result of `res.send()` or the callback function.
*
* @example
* appManager.send(res, 404); // Sends 404 Not Found with empty body
*/
sendHttpError(res, code) {
if (!res || typeof res.status !== 'function' || typeof res.send !== 'function')
throw new Error('Expected a valid Express Response object as the first argument');
if (typeof code !== 'number' || !Number.isInteger(code))
throw new Error('Expected an integer HTTP status code as the second argument');
const statusText = this.#httpCodes?.[code];
if (typeof statusText !== 'string')
throw new Error(`Unknown HTTP status code: ${code}`);
res.header(`HTTP/1.0 ${code} ${this.#httpCodes[code]}`);
return res.status(code).end();
}
/**
* Installs default error-handling middleware into the Express app instance.
*
* This includes:
* - A catch-all 404 handler that throws a `HttpError` when no routes match.
* - A global error handler that responds with a JSON-formatted error response,
* including the stack trace in development environments.
*
* @param {Object} [options={}]
* @param {string} [options.notFoundMsg='Page not found.']
* @param {function(HttpStatusCode, HttpError, Request, Response): any} [options.errNext]
*/
installErrors({ notFoundMsg = 'Page not found.', errNext = (status, err, req, res) => {
res.json({
status,
message: typeof err.message === 'string'
? err.message
: this.hasHttpStatusMessage(status)
? this.getHttpStatusMessage(status)
: '',
stack: process.env.NODE_ENV === 'development' ? err.stack : null,
});
}, } = {}) {
const app = this.getRoot();
// Middleware 404
app.use(
/** @type {RequestHandler} */ (req, res, next) => {
const err = createHttpError(404, notFoundMsg);
return next(err);
});
// Middleware global de erro
app.use(
/** @type {ErrorRequestHandler} */
(err, req, res, _next) => {
const status = err.status || err.statusCode || 500;
res.status(status);
return errNext(status, err, req, res);
});
}
/**
* Express middleware for basic HTTP authentication.
*
* Validates the `Authorization` header against provided login credentials.
* If credentials match, the request proceeds to the next middleware.
* Otherwise, responds with HTTP 401 Unauthorized.
*
* @param {Request} req - Express request object.
* @param {Response} res - Express response object.
* @param {NextFunction} next - Express next middleware function.
* @param {Object} [options={}] - Optional configuration object.
* @param {string} [options.login] - Expected username for authentication.
* @param {string} [options.password] - Expected password for authentication.
* @param {RequestHandler|null} [options.nextError] - Optional error handler middleware called on failed auth.
* @param {(login: string, password: string) => boolean|Promise<boolean>} [options.validator] - Optional function to validate credentials.
*/
async authRequest(req, res, next, { login = '', password = '', nextError = null, validator } = {}) {
// authentication middleware
const auth = { login, password };
// Parse credentials from Authorization header
const b64auth = (req.headers.authorization || '').split(' ')[1] || '';
const [authLogin, authPassword] = Buffer.from(b64auth, 'base64').toString().split(':');
// Custom validator or static comparison
const valid = typeof validator === 'function'
? await validator(authLogin, authPassword)
: authLogin === auth.login && authPassword === auth.password;
if (authLogin && authPassword && valid) {
return next(); // Access granted
}
// Access denied
res.set('WWW-Authenticate', 'Basic realm="401"');
if (typeof nextError === 'function') {
res.status(401);
nextError(req, res, next);
return;
}
res.status(401).end();
}
/**
* Sends a file response with appropriate headers.
*
* This function sends a file (as a Buffer) directly in the HTTP response,
* setting standard headers such as Content-Type, Cache-Control, Last-Modified, and Content-Disposition.
*
* @param {Response} res - The HTTP response object to send the file through.
* @param {Object} [options={}] - Configuration options for the file response.
* @param {string} [options.contentType='text/plain'] - The MIME type of the file being sent.
* @param {number} [options.fileMaxAge=0] - Max age in seconds for the Cache-Control header.
* @param {Buffer} [options.file] - The file contents to send as a buffer. Required.
* @param {Date | number | string | null} [options.lastModified] - The last modification time for the Last-Modified header.
* @param {string | null} [options.fileName] - Optional file name for the Content-Disposition header.
* @throws {Error} If required options are missing or invalid.
* @beta
*/
sendFile(res, { contentType = 'text/plain', fileMaxAge = 0, file, lastModified, fileName = null } = {}) {
if (!Buffer.isBuffer(file))
throw new Error('"file" must be a Buffer instance.');
if (!Number.isInteger(fileMaxAge) || fileMaxAge < 0)
throw new Error('"fileMaxAge" must be a non-negative integer.');
if (typeof fileName !== 'string' && fileName !== null)
throw new Error('"fileName" must be a string.');
if (lastModified !== null &&
typeof lastModified !== 'string' &&
typeof lastModified !== 'number' &&
!(lastModified instanceof Date))
throw new Error('"lastModified" is not a valid date value.');
// File Type Headers
res.setHeader('Content-Type', contentType);
res.setHeader('Accept-Ranges', 'bytes');
// Content-MD5 Header
res.setHeader('Content-MD5', createHash('md5').update(file).digest('base64'));
// Last-Modified Header
if (typeof lastModified === 'string' ||
typeof lastModified === 'number' ||
lastModified instanceof Date) {
const date = new Date(lastModified);
if (!Number.isNaN(date.getTime()))
res.setHeader('Last-Modified', date.toUTCString());
}
// ETag Header (weak ETag based on MD5 hash)
const etag = `"${createHash('md5').update(file).digest('hex')}"`;
res.setHeader('ETag', etag);
// Cache Control Headers
const expires = new Date(Date.now() + fileMaxAge);
res.setHeader('Expires', expires.toUTCString());
res.setHeader('Cache-Control', `public, max-age=${fileMaxAge}`);
// Pragma header (legacy)
if (fileMaxAge === 0) {
res.setHeader('Pragma', 'no-cache');
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
}
// Content Length
const byteLength = file.byteLength;
if (!Number.isInteger(byteLength) || byteLength < 0)
throw new Error('Failed to determine valid file length.');
res.setHeader('Content-Length', byteLength);
if (typeof fileName === 'string')
res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`);
// Send Response
res.send(file);
}
/**
* Streams a file response with support for range headers (video/audio streaming).
*
* This function streams a file or provided readable stream to the client, supporting HTTP range requests.
* It is useful for serving large media files where partial content responses are required.
*
* @param {Request} req - The HTTP request object, used to detect range headers.
* @param {Response} res - The HTTP response object to stream the file through.
* @param {Object} [options={}] - Configuration options for streaming.
* @param {boolean} [options.rangeOnlyMode=false] - When enabled, prevents full file streaming and enforces HTTP Range-based partial responses only.
* @param {string} [options.filePath] - The absolute file path to stream. Required if no stream is provided.
* @param {ReadableStream} [options.stream] - A readable stream to use instead of reading from filePath.
* @param {string} [options.contentType='application/octet-stream'] - The MIME type of the streamed content.
* @param {number} [options.fileMaxAge=0] - Max age in seconds for the Cache-Control header.
* @param {Date | number | string | null} [options.lastModified] - The last modification time for the Last-Modified header.
* @param {string | null} [options.fileName] - Optional file name for the Content-Disposition header.
* @beta
*/
async streamFile(req, res, { filePath, stream, contentType = 'application/octet-stream', fileMaxAge = 0, lastModified = null, fileName = null, rangeOnlyMode = false, } = {}) {
const range = req.headers.range;
let total = 0;
let statData = null;
if (filePath) {
if (typeof filePath !== 'string')
throw new Error('"filePath" must be a valid file path string.');
if (!existsSync(filePath))
throw new Error(`File "${filePath}" not found.`);
statData = await stat(filePath);
total = statData.size;
}
else if (!(stream instanceof ReadableStream))
throw new Error('Either "filePath" or "stream" must be provided.');
res.setHeader('Content-Type', contentType);
res.setHeader('Accept-Ranges', 'bytes');
// Last-Modified
const lastModDate = new Date(lastModified || statData?.mtime || Date.now());
res.setHeader('Last-Modified', lastModDate.toUTCString());
// Cache headers
const expires = new Date(Date.now() + fileMaxAge);
res.setHeader('Expires', expires.toUTCString());
res.setHeader('Cache-Control', fileMaxAge > 0 ? `public, max-age=${fileMaxAge}` : 'no-cache, no-store, must-revalidate');
if (fileMaxAge === 0)
res.setHeader('Pragma', 'no-cache');
if (typeof fileName === 'string')
res.setHeader('Content-Disposition', `inline; filename="${fileName}"`);
// --- RANGE SUPPORT ONLY IF filePath is available ---
if (range && filePath) {
const [startStr, endStr] = range.replace(/bytes=/, '').split('-');
const start = parseInt(startStr, 10);
const end = endStr ? parseInt(endStr, 10) : total - 1;
if (Number.isNaN(start) || Number.isNaN(end) || start > end || end >= total) {
res.status(416).setHeader('Content-Range', `bytes */${total}`).end();
return;
}
const chunkSize = end - start + 1;
res.status(206); // Partial Content
res.setHeader('Content-Range', `bytes ${start}-${end}/${total}`);
res.setHeader('Content-Length', chunkSize);
const partialStream = createReadStream(filePath, { start, end });
partialStream.pipe(res);
}
else {
if (filePath) {
if (rangeOnlyMode && !range) {
const start = 0;
const end = Math.min(total - 1, 1024 * 512); // First 512KB
const partialStream = createReadStream(filePath, { start, end });
res.status(206);
res.setHeader('Content-Range', `bytes ${start}-${end}/${total}`);
res.setHeader('Content-Length', end - start + 1);
partialStream.pipe(res);
}
else {
res.setHeader('Content-Length', total);
const fullStream = createReadStream(filePath);
fullStream.pipe(res);
}
}
else if (stream instanceof ReadableStream) {
// Using provided stream
stream.pipe(res);
}
else
throw new Error('Either "stream" must be provided.');
}
}
/**
* Enables a lightweight test-only Express mode for serving static files and open API testing.
*
* This method sets up an unrestricted CORS environment and exposes a static folder,
* allowing any origin to access the content and APIs freely. It also includes permissive
* CORS headers and custom middleware headers for development diagnostics.
*
* ⚠️ **Do not use this in production!**
* This mode disables all security measures and is intended **only** for local development,
* debugging, or experimentation. All origins, headers, and methods are allowed without validation.
*
* @throws {Error} If the current environment is not "development".
* @throws {TypeError} If `folder` is not a non-empty string.
*
* @param {string} folder - The path to the folder that will be served as static content.
*/
freeMode(folder) {
if (process.env.NODE_ENV !== 'development')
throw new Error('[freeMode] This method is only allowed in development mode.');
if (typeof folder !== 'string' || folder.trim() === '')
throw new TypeError('[freeMode] Expected "folder" to be a non-empty string.');
this.root.use(express.static(folder));
this.root.use((req, res, next) => {
// Website you wish to allow to connect
res.setHeader('Access-Control-Allow-Origin', '*');
// Request methods you wish to allow
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS, PUT, PATCH, DELETE');
// Request headers you wish to allow
res.setHeader('Access-Control-Allow-Headers', 'X-Requested-With,content-type');
// Set to true if you need the website to include cookies in the requests sent
// to the API (e.g. in case you use sessions)
res.setHeader('Access-Control-Allow-Credentials', 'true');
// Pass to next layer of middleware
next();
});
}
}
export default TinyExpress;