* Utility and miscellaneous functions.
*/
-const path = require('path');
-const crypto = require('crypto');
+const crypto = require('node:crypto');
const uuid = require('uuid');
const Enum = require('./enum');
+const { fileScope } = require('@squeep/log-helper');
/**
- * Return a function which combines a part of the filename with a scope, for use in logging.
- * @param {string} filename
+ * @typedef {import('node:http')} http
*/
-const fileScope = (filename) => {
- let fScope = path.basename(filename, '.js');
- if (fScope === 'index') {
- fScope = path.basename(path.dirname(filename));
- }
- return (scope) => `${fScope}:${scope}`;
-}
/**
* Simple ETag from data.
- * @param {string} filePath
- * @param {object} fileStat
- * @param {*} fileData
+ * @param {string} _filePath (currently unused)
+ * @param {object} fileStat node:fs.Stats object
+ * @param {number} fileStat.mtimeMs node:fs.Stats object
+ * @param {crypto.BinaryLike} fileData content
+ * @returns {string} etag
*/
const generateETag = (_filePath, fileStat, fileData) => {
const hash = crypto.createHash('sha256');
- if (fileStat && fileStat.mtimeMs) {
+ if (fileStat?.mtimeMs) {
hash.update(fileStat.mtimeMs.toString());
}
hash.update(fileData);
};
/**
- * @param {object} obj
- * @param {string} prop
- * @param {*} def
+ * Access property with default.
+ * @param {object} obj target object
+ * @param {string} prop target property
+ * @param {*} def default value if prop does not exist for obj
+ * @returns {*} property value or default
*/
const get = (obj, prop, def) => obj && prop && (prop in obj) ? obj[prop] : def;
/**
- * @param {http.ClientRequest} req
- * @param {http.ServerResponse} res
- * @param {object} ctx
- */
-const handlerLogData = (req, res, ctx) => ({
- req: requestLogData(req),
- res: responseLogData(res),
- ctx,
-});
-
-/**
- *
- * @param {http.ClientRequest} req
- * @param {Number} modifiedTimeMs
- * @param {string} eTag
+ * Determine whether a client has already requested a resource,
+ * based on If-Modified-Since and If-None-Match headers.
+ * @param {http.ClientRequest} req request
+ * @param {(string) => string} req.getHeader header accessor
+ * @param {number} modifiedTimeMs ms timestamp from client
+ * @param {string} eTag etag from client
+ * @returns {boolean} whether our version matches what client knows
*/
const isClientCached = (req, modifiedTimeMs, eTag) => {
let clientCached = false;
/**
* Shallow merge for enums, to be called by derived constructor.
* Expects only one-level deep, is not recursive!
- * @param {Object} origEnum
- * @param {Object} additionalEnum
+ * @param {object} origEnum enum object to be extended
+ * @param {object} additionalEnum enum object to add
+ * @returns {object} lightly merged enum object
*/
const mergeEnum = (origEnum, additionalEnum) => {
for (const e of Object.keys(additionalEnum)) {
/**
* Isolate the general category of an http status code.
- * @param {Number} statusCode
- * @returns {Number}
+ * @param {number} statusCode of response
+ * @returns {number} status category
*/
const httpStatusCodeClass = (statusCode) => Math.floor(statusCode / 100);
const _isObject = (obj) => obj && typeof obj === 'object';
const _isArray = (obj) => Array.isArray(obj);
+
/**
- * Return a new object with all objects combined.
- * @param {...any} objects
- * @returns
+ * Return a new object with all objects combined, later properties taking precedence.
+ * Arrays are concated.
+ * @param {...object} objects to be merged onto a new object
+ * @returns {object} new merged object
*/
const mergeDeep = (...objects) => {
return objects.reduce((acc, obj) => {
/**
* Return a new object with selected props.
- * @param {Object} obj
- * @param {string[]} props
+ * @param {object} obj source object
+ * @param {string[]} props list of property names
+ * @returns {object} object with selected properties
*/
const pick = (obj, props) => {
const picked = {};
};
/**
- * Return a subset of a request object, suitable for logging.
- * @param {http.ClientRequest} req
- */
-const requestLogData = (req) => {
- return pick(req, [
- 'method',
- 'url',
- 'httpVersion',
- 'headers',
- 'trailers',
- ]);
-};
-
-
-/**
- * Return a subset of a response object, suitable for logging.
- * @param {http.ServerResponse} res
- */
-const responseLogData = (res) => {
- const response = pick(res, [
- 'statusCode',
- 'statusMessage',
- ]);
- response.headers = res.getHeaders();
- return response;
-};
-
-
-/**
- * Store updates to defaultOptions, but no new properties.
- * @param {Object} target
- * @param {Object} defaultOptions
- * @param {Object} options
+ * Store all properties in defaultOptions on target from either options or defaultOptions.
+ * @param {object} target object to populate
+ * @param {object} defaultOptions object with default property values
+ * @param {object} options object with potential overrides for defaults
+ * @returns {object} object with properties
*/
const setOptions = (target, defaultOptions, options) => {
Object.assign(target, defaultOptions, pick(options, Object.keys(defaultOptions)));
+ return target;
};
/**
- * Return a list of source split at first delimiter.
- * @param {string} src
- * @param {string} delimiter
+ * Return a two-item list of src, split at first delimiter encountered.
+ * @param {string} src source
+ * @param {string} delimiter delimiter
* @param {string} fill trailing stand-in if no delimiter in src
+ * @returns {string[]} [before-first-delimiter, rest-or-fill]
*/
const splitFirst = (src, delimiter, fill) => {
const idx = src.indexOf(delimiter);
};
/**
- * Generate a new request identifier.
- * @returns {String}
+ * Generate a new request identifier, a time/host-based uuid.
+ * @returns {string} uuid
*/
const requestId = () => {
return uuid.v1();
};
-const nop = () => { /**/ };
-const nullLogger = {
- error: nop,
- warn: nop,
- info: nop,
- log: nop,
- debug: nop,
+/**
+ * Merges folded header lines
+ * @param {string[]} lines header lines
+ * @returns {string} unfolded header string
+ */
+const unfoldHeaderLines = (lines) => {
+ const foldedLineRE = /^(\t| +)(.*)$/;
+ if (lines) {
+ lines.reduceRight((_, line, idx) => { // NOSONAR
+ const result = foldedLineRE.exec(line);
+ if (result && idx) {
+ const prevIdx = idx - 1;
+ const mergedLine = `${lines[prevIdx]} ${result[2]}`;
+ lines.splice(prevIdx, 2, mergedLine);
+ return mergedLine;
+ }
+ }, null);
+ }
+ return lines;
};
-const ensureLoggerLevels = (logger = {}) => {
- for (const level in nullLogger) {
- if (! (level in logger)) {
- logger[level] = nullLogger[level];
+const validTokenRE = /^[!#$%&'*+-.0-9A-Z^_`a-z~]+$/;
+const validValueRE = /^[!#$%&'()*+-./0-9:<=>?@A-Z[\]^_`a-z{|}~]*$/;
+const validPathRE = /^[ !"#$%&'()*+,-./0-9:<=>?@A-Z[\\\]^_`a-z{|}~]*$/;
+const validLabelRE = /^[a-zA-Z0-9-]+$/;
+const invalidLabelRE = /--|^-|-$/;
+
+/**
+ * Adds a new set-cookie header value to response, with supplied data.
+ * @param {http.ServerResponse} res response
+ * @param {(string, string) => void} res.appendHeader sets header values
+ * @param {string} name cookie name
+ * @param {string} value cookie value
+ * @param {object=} opt cookie options
+ * @param {string=} opt.domain cookie domain
+ * @param {Date=} opt.expires cookie expiration
+ * @param {boolean=} opt.httpOnly cookie client visibility
+ * @param {number=} opt.maxAge cookie lifetime
+ * @param {string=} opt.path cookie path
+ * @param {string=} opt.sameSite cookie sharing
+ * @param {boolean=} opt.secure cookie security
+ * @param {string[]=} opt.extension cookie extension attribute values
+ */
+function addCookie(res, name, value, opt = {}) {
+ const options = {
+ domain: undefined,
+ expires: undefined,
+ httpOnly: false,
+ maxAge: undefined,
+ path: undefined,
+ sameSite: undefined,
+ secure: false,
+ extension: [],
+ ...opt,
+ };
+
+ if (!validTokenRE.test(name)) {
+ throw new RangeError('invalid cookie name');
+ }
+
+ if (value.startsWith('"') && value.endsWith('"')) {
+ if (!validValueRE.test(value.slice(1, value.length - 1))) {
+ throw new RangeError('invalid cookie value');
+ };
+ } else if (!validValueRE.test(value)) {
+ throw new RangeError('invalid cookie value');
+ }
+
+ const cookieParts = [
+ `${name}=${value}`,
+ ];
+
+ if (options.domain) {
+ for (const label of options.domain.split('.')) {
+ if (!validLabelRE.test(label) || invalidLabelRE.test(label)) {
+ throw new RangeError('invalid cookie domain');
+ }
+
}
+ cookieParts.push(`Domain=${options.domain}`);
}
- return logger;
-};
+
+ if (options.expires) {
+ if (!(options.expires instanceof Date)) {
+ throw new TypeError('cookie expires must be Date');
+ }
+ cookieParts.push(`Expires=${options.expires.toUTCString()}`);
+ }
+
+ if (options.httpOnly) {
+ cookieParts.push('HttpOnly');
+ }
+
+ if (options.maxAge) {
+ cookieParts.push(`Max-Age=${options.maxAge}`);
+ }
+
+ if (options.path) {
+ if (!validPathRE.test(options.path)) {
+ throw new RangeError('cookie path value not valid');
+ }
+ cookieParts.push(`Path=${options.path}`);
+ }
+
+ if (options.sameSite) {
+ if (!(['Strict', 'Lax', 'None'].includes(options.sameSite))) {
+ throw new RangeError('cookie sameSite value not valid');
+ }
+ if (options.sameSite === 'None'
+ && !options.secure) {
+ throw new RangeError('cookie with sameSite None must also be secure');
+ }
+ cookieParts.push(`SameSite=${options.sameSite}`);
+ }
+
+ if (options.secure) {
+ cookieParts.push('Secure');
+ }
+
+ if (!Array.isArray(options.extension)) {
+ throw new TypeError('cookie extension must be Array');
+ }
+ for (const extension of options.extension) {
+ if (!validPathRE.test(extension)) {
+ throw new RangeError('cookie extension value not valid');
+ }
+ cookieParts.push(extension);
+ }
+
+ res.appendHeader(Enum.Header.SetCookie, cookieParts.join('; '));
+}
+
module.exports = {
+ addCookie,
fileScope,
generateETag,
get,
- handlerLogData,
- isClientCached,
httpStatusCodeClass,
+ isClientCached,
mergeDeep,
mergeEnum,
- nop,
- nullLogger,
- ensureLoggerLevels,
pick,
requestId,
- requestLogData,
- responseLogData,
setOptions,
splitFirst,
+ unfoldHeaderLines,
};