diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f031dad..6c72b73 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - node: ['18', '20', '22', '23'] + node: ['22', '24', '26'] steps: - uses: actions/checkout@v7 - name: Setup node ${{ matrix.node }} diff --git a/eslint.config.js b/eslint.config.js index fdf5e46..b1b29c2 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -13,7 +13,7 @@ module.exports = [ globals: { ...globals.node }, - ecmaVersion: 2020, + ecmaVersion: 2023, sourceType: 'commonjs' } } diff --git a/examples/docker/config.js b/examples/docker/config.js index dad8db5..7fb99d1 100644 --- a/examples/docker/config.js +++ b/examples/docker/config.js @@ -1,4 +1,4 @@ -const path = require('path'); +const path = require('node:path'); module.exports = { resourcesPath: path.join(__dirname, 'resources'), diff --git a/examples/docker/worker.js b/examples/docker/worker.js index 38ed674..432f3a4 100644 --- a/examples/docker/worker.js +++ b/examples/docker/worker.js @@ -1,5 +1,5 @@ const flora = require('flora'); -const server = new flora.Server(require('path').join(__dirname, 'config.js')); +const server = new flora.Server(require('node:path').join(__dirname, 'config.js')); server.run(); diff --git a/examples/express/README.md b/examples/express/README.md index dbfb66b..dbb1181 100644 --- a/examples/express/README.md +++ b/examples/express/README.md @@ -5,7 +5,7 @@ This is an example for an adapter between Flora and Express. ```js const express = require('express'); const flora = require('flora'); -const path = require('path'); +const path = require('node:path'); const floraExpress = require('./'); // Flora diff --git a/examples/express/index.js b/examples/express/index.js index abe6bbd..2b7a03a 100644 --- a/examples/express/index.js +++ b/examples/express/index.js @@ -2,7 +2,7 @@ const errors = require('@florajs/errors'); const flora = require('flora'); -const { URL } = require('url'); +const { URL } = require('node:url'); module.exports = function (api) { function sendResponse(response, httpRequest, httpResponse) { @@ -49,7 +49,7 @@ module.exports = function (api) { if (matches[3]) opts.format = matches[3]; parsedUrl.searchParams.forEach((value, param) => { - if (!Object.prototype.hasOwnProperty.call(opts, param)) { + if (!Object.hasOwn(opts, param)) { if (parsedUrl.searchParams.getAll(param).length > 1) { return next(new errors.RequestError(`Duplicate parameter "${param}" in URL`)); } diff --git a/examples/extensions/config.js b/examples/extensions/config.js index 2a29a89..7588f28 100644 --- a/examples/extensions/config.js +++ b/examples/extensions/config.js @@ -1,4 +1,4 @@ -const path = require('path'); +const path = require('node:path'); class EmptyDataSource { async process(/* request */) { diff --git a/examples/extensions/server.js b/examples/extensions/server.js index 0cad0af..d4e098d 100644 --- a/examples/extensions/server.js +++ b/examples/extensions/server.js @@ -1,6 +1,6 @@ 'use strict'; -const path = require('path'); +const path = require('node:path'); const flora = require('flora'); const server = new flora.Server(path.join(__dirname, 'config.js')); diff --git a/examples/native/native.js b/examples/native/native.js index d0acfd1..71cfd07 100644 --- a/examples/native/native.js +++ b/examples/native/native.js @@ -1,4 +1,4 @@ -const path = require('path'); +const path = require('node:path'); const flora = require('flora'); /* diff --git a/examples/plugins/master.js b/examples/plugins/master.js index 4cfda93..da77077 100644 --- a/examples/plugins/master.js +++ b/examples/plugins/master.js @@ -1,4 +1,4 @@ -const path = require('path'); +const path = require('node:path'); const flora = require('flora'); /* diff --git a/examples/plugins/worker.js b/examples/plugins/worker.js index 308acc0..7bdc069 100644 --- a/examples/plugins/worker.js +++ b/examples/plugins/worker.js @@ -1,4 +1,4 @@ -const path = require('path'); +const path = require('node:path'); const flora = require('flora'); /* diff --git a/examples/simple/config.js b/examples/simple/config.js index 6b2c3e0..c55198c 100644 --- a/examples/simple/config.js +++ b/examples/simple/config.js @@ -1,4 +1,4 @@ -const path = require('path'); +const path = require('node:path'); module.exports = { exec: path.join(__dirname, 'worker.js'), diff --git a/examples/simple/master.js b/examples/simple/master.js index d573231..f5f7969 100644 --- a/examples/simple/master.js +++ b/examples/simple/master.js @@ -1,4 +1,4 @@ -const path = require('path'); +const path = require('node:path'); const flora = require('flora'); const master = new flora.Master(path.join(__dirname, 'config.js')); diff --git a/examples/simple/worker.js b/examples/simple/worker.js index b05cd1f..578c326 100644 --- a/examples/simple/worker.js +++ b/examples/simple/worker.js @@ -1,4 +1,4 @@ -const path = require('path'); +const path = require('node:path'); const flora = require('flora'); const server = new flora.Server(path.join(__dirname, 'config.example.js')); diff --git a/lib/api.js b/lib/api.js index 3f6f5fc..2dd414b 100644 --- a/lib/api.js +++ b/lib/api.js @@ -186,7 +186,7 @@ class Api extends PromiseEventEmitter { const resource = this.getResource(request.resource); if (!resource) throw new NotFoundError(`Unknown resource "${request.resource}" in request`); - if (!resource.actions || !Object.prototype.hasOwnProperty.call(resource.actions, request.action)) { + if (!resource.actions || !Object.hasOwn(resource.actions, request.action)) { throw new RequestError(`Action "${request.action}" is not implemented`); } @@ -200,7 +200,7 @@ class Api extends PromiseEventEmitter { throw new RequestError(`Invalid format "${request.format}" for action "${request.action}"`); } if ( - !Object.prototype.hasOwnProperty.call(resource.actions[request.action], method) || + !Object.hasOwn(resource.actions[request.action], method) || typeof resource.actions[request.action][method] !== 'function' ) { throw new RequestError(`Invalid format "${request.format}" for action "${request.action}"`); @@ -277,8 +277,7 @@ class Api extends PromiseEventEmitter { * @returns {*} */ getPlugin(name) { - if (!Object.prototype.hasOwnProperty.call(this.plugins, name)) - throw new Error(`Plugin "${name}" is not registered`); + if (!Object.hasOwn(this.plugins, name)) throw new Error(`Plugin "${name}" is not registered`); return this.plugins[name]; } } diff --git a/lib/ascii-art-profile.js b/lib/ascii-art-profile.js index 1333139..35cc481 100644 --- a/lib/ascii-art-profile.js +++ b/lib/ascii-art-profile.js @@ -1,9 +1,5 @@ 'use strict'; -function strRepeat(str, count) { - return new Array(count + 1).join(str); -} - module.exports = function asciiArtProfile(profile, totalDuration, width) { if (totalDuration <= 0) return ['total duration = ' + totalDuration + 'ms!? Wow ... that was fast :-)']; if (width < 10) width = 10; @@ -12,14 +8,12 @@ module.exports = function asciiArtProfile(profile, totalDuration, width) { const beforeChar = '.'; const afterChar = '.'; let endChar = ''; - let beforeWidth = 0; let beginChar = ''; let durationChar = '#'; - let durationWidth = 0; - let afterWidth = 0; - let description = ''; + let afterWidth; + let description; - beforeWidth = Math.round((measure.startTime * width) / totalDuration); + let beforeWidth = Math.round((measure.startTime * width) / totalDuration); if (beforeWidth > width) { beforeWidth = width; beginChar = '>'; @@ -29,6 +23,7 @@ module.exports = function asciiArtProfile(profile, totalDuration, width) { beginChar = '<'; } + let durationWidth; if (measure.duration !== null) { durationWidth = Math.round((measure.duration * width) / totalDuration); if (durationWidth > width - beforeWidth) { @@ -50,7 +45,6 @@ module.exports = function asciiArtProfile(profile, totalDuration, width) { } else { durationWidth = width - beforeWidth; durationChar = '?'; - afterWidth = 0; description = ' (' + measure.name + ' - still running!)'; } @@ -63,11 +57,11 @@ module.exports = function asciiArtProfile(profile, totalDuration, width) { } return ( - strRepeat(beforeChar, beforeWidth) + + beforeChar.repeat(beforeWidth) + beginChar + - strRepeat(durationChar, durationWidth) + + durationChar.repeat(durationWidth) + endChar + - strRepeat(afterChar, afterWidth) + + afterChar.repeat(afterWidth) + description ); }); diff --git a/lib/cast.js b/lib/cast.js index e0c335b..6f14dcd 100644 --- a/lib/cast.js +++ b/lib/cast.js @@ -147,7 +147,7 @@ class Cast { } if (value === null) return value; - if (Object.prototype.hasOwnProperty.call(casts, opts.type)) return casts[opts.type](value, opts, this.api); + if (Object.hasOwn(casts, opts.type)) return casts[opts.type](value, opts, this.api); return value; } diff --git a/lib/config-loader.js b/lib/config-loader.js index fb614c1..4b27b1f 100644 --- a/lib/config-loader.js +++ b/lib/config-loader.js @@ -1,41 +1,9 @@ 'use strict'; const fs = require('node:fs/promises'); -const path = require('path'); +const path = require('node:path'); -/** - * Read config files from directory recursively. - * - * @param {string} configDirectory - * @param {string} resourceName - * @param {object} resources - * @return {Array} - * @private - */ -async function walk(configDirectory, resourceName, resources) { - resourceName = resourceName || ''; - resources = resources || {}; - - for (const fileName of await fs.readdir(path.join(configDirectory, resourceName))) { - const subResourceName = (resourceName !== '' ? resourceName + '/' : '') + fileName; - const absoluteFilePath = path.join(configDirectory, subResourceName); - const stat = await fs.stat(absoluteFilePath); - if (stat && stat.isDirectory()) { - await walk(configDirectory, subResourceName, resources); - } else if (resourceName !== '') { - if (fileName.startsWith('config.')) { - if (!resources[resourceName]) resources[resourceName] = {}; - resources[resourceName].configFile = absoluteFilePath; - } - if (fileName === 'index.js') { - if (!resources[resourceName]) resources[resourceName] = {}; - resources[resourceName].instanceFile = absoluteFilePath; - } - } - } - - return resources; -} +const { ImplementationError } = require('@florajs/errors'); /** * Load resource configs from config directory. @@ -53,71 +21,47 @@ module.exports = async function configLoader(api, options) { }, ...options }; - let resources; - const configDirectory = path.resolve(cfg.directory); const configParsers = cfg.parsers; - if ( - !(await fs - .access(configDirectory) - .then(() => true) - .catch(() => false)) - ) { - throw new Error(`Config directory "${configDirectory}" does not exist`); - } - try { - resources = await walk(configDirectory); + await fs.access(configDirectory); } catch (err) { - err.message = 'Error reading resource directory tree: ' + err.message; - throw err; + throw new ImplementationError(`Cannot access config directory "${configDirectory}"`, { cause: err }); } - // parse all configs - await Promise.all( - Object.keys(resources).map(async (resourceName) => { - const file = resources[resourceName].configFile; - if (!file) return null; + const resources = {}; + for await (const entry of fs.glob('*/**/{config.*,index.js}', { cwd: configDirectory, withFileTypes: true })) { + if (!entry.isFile()) continue; + + const resourceName = path.relative(configDirectory, entry.parentPath).split(path.sep).join('/'); + const absoluteFilePath = path.join(entry.parentPath, entry.name); - const extension = path.extname(file); - const type = extension.substring(1); + resources[resourceName] ??= {}; + + if (entry.name.startsWith('config.')) { + const type = path.extname(entry.name).substring(1); const parseConfig = configParsers[type]; - if (!parseConfig) return Promise.reject(new Error(`No "${type}" config parser registered`)); + if (!parseConfig) throw new ImplementationError(`No "${type}" config parser registered`); - api.log.trace('Parsing config for resource ' + resourceName); + api.log.trace(`Parsing config for resource ${resourceName}`); try { - resources[resourceName].config = await parseConfig(file); - delete resources[resourceName].configFile; - } catch (e) { - e.message = `Error parsing resource "${resourceName}": ${e.message}`; - throw e; + resources[resourceName].config = await parseConfig(absoluteFilePath); + } catch (err) { + throw new ImplementationError(`Error parsing resource "${resourceName}"`, { cause: err }); } - }) - ); - - // load all resources - await Promise.all( - Object.keys(resources).map((resourceName) => { - if (!resources[resourceName].instanceFile) return null; - - return new Promise((resolve, reject) => { - api.log.trace('Loading resource ' + resourceName); - const resourceFunction = require(resources[resourceName].instanceFile); - if (typeof resourceFunction !== 'function') { - return reject( - new Error(`Resource does not export a function: ${resources[resourceName].instanceFile}`) - ); - } - resources[resourceName].instance = resourceFunction(api); + } - delete resources[resourceName].instanceFile; - resolve(); - }); - }) - ); + if (entry.name === 'index.js') { + api.log.trace(`Loading resource ${resourceName}`); + const resourceFunction = require(absoluteFilePath); + if (typeof resourceFunction !== 'function') { + throw new ImplementationError(`Resource does not export a function: ${absoluteFilePath}`); + } + resources[resourceName].instance = resourceFunction(api); + } + } - // done return resources; }; diff --git a/lib/config-parser.js b/lib/config-parser.js index c0e40ee..b58f5bd 100644 --- a/lib/config-parser.js +++ b/lib/config-parser.js @@ -129,7 +129,7 @@ function parseMap(map, context) { * @private */ function checkWhitelist(str, whitelist, context) { - if (whitelist.indexOf(str) === -1) { + if (!whitelist.includes(str)) { throw new ImplementationError( 'Invalid "' + str + '" (allowed: ' + whitelist.join(', ') + ')' + context.errorContext ); @@ -642,7 +642,7 @@ function prepareDataSources(attrNode, context) { } // make attributes unique: - dataSourceAttributes = dataSourceAttributes.filter((value, index, self) => self.indexOf(value) === index); + dataSourceAttributes = [...new Set(dataSourceAttributes)]; try { dataSourceInstance.prepare(dataSource, dataSourceAttributes); diff --git a/lib/datasource-executor.js b/lib/datasource-executor.js index 16c3604..d028c95 100644 --- a/lib/datasource-executor.js +++ b/lib/datasource-executor.js @@ -112,13 +112,13 @@ async function executeDst(api, request, dst) { const pvs = []; for (let i = 0; i < subFilterResult.data.length; i++) { if (subFilterResult.parentKey.length === 1) { - if (Object.prototype.hasOwnProperty.call(subFilterResult.data[i], subFilterResult.childKey[0])) { + if (Object.hasOwn(subFilterResult.data[i], subFilterResult.childKey[0])) { pvs.push(subFilterResult.data[i][subFilterResult.childKey[0]]); } } else { let partEmpty = false; const part = subFilterResult.childKey.map((childKeyPart) => { - if (!Object.prototype.hasOwnProperty.call(subFilterResult.data[i], childKeyPart)) { + if (!Object.hasOwn(subFilterResult.data[i], childKeyPart)) { partEmpty = true; return null; } @@ -142,7 +142,7 @@ async function executeDst(api, request, dst) { dst.request.filter.forEach((orFilter) => { const orFilterNew = []; orFilter.forEach((andFilter) => { - if (!Object.prototype.hasOwnProperty.call(andFilter, 'valueFromSubFilter')) { + if (!Object.hasOwn(andFilter, 'valueFromSubFilter')) { orFilterNew.push(andFilter); return; } @@ -152,7 +152,7 @@ async function executeDst(api, request, dst) { } parentValues[andFilter.valueFromSubFilter].forEach((pv) => { - const andfilterNew = Object.assign({}, andFilter); + const andfilterNew = { ...andFilter }; if (pv.length === 0) { andfilterNew.empty = true; @@ -236,7 +236,7 @@ async function executeDst(api, request, dst) { 'uniqueChildKey', 'multiValuedChildKey' ].forEach((key) => { - if (Object.prototype.hasOwnProperty.call(dst, key)) mainResults[key] = dst[key]; + if (Object.hasOwn(dst, key)) mainResults[key] = dst[key]; }); if (!dst._isEmpty) { @@ -362,7 +362,7 @@ async function executeDst(api, request, dst) { if (isNull) return; if (flatten) { - if (Array.isArray(value)) Array.prototype.push.apply(parentValues, value); + if (Array.isArray(value)) parentValues.push(...value); } else { parentValues.push(value); } diff --git a/lib/master.js b/lib/master.js index 061713e..49968e8 100644 --- a/lib/master.js +++ b/lib/master.js @@ -1,6 +1,6 @@ 'use strict'; -const path = require('path'); +const path = require('node:path'); const bunyan = require('bunyan'); const ClusterMaster = require('@florajs/cluster').Master; diff --git a/lib/request-resolver.js b/lib/request-resolver.js index 14c0cec..aa25e69 100644 --- a/lib/request-resolver.js +++ b/lib/request-resolver.js @@ -11,7 +11,7 @@ const { RequestError, ImplementationError } = require('@florajs/errors'); */ function pick(obj, properties) { return properties - .filter((property) => Object.prototype.hasOwnProperty.call(obj, property)) + .filter((property) => Object.hasOwn(obj, property)) .reduce((acc, property) => ({ ...acc, [property]: obj[property] }), {}); } @@ -189,8 +189,7 @@ function getAttributeWithContext(path, attrNode, context) { } Object.keys(origSubAttrNode).forEach((optionName) => { - // eslint-disable-next-line no-prototype-builtins - if (subAttrNode.hasOwnProperty(optionName)) return; // for inherit + if (Object.hasOwn(subAttrNode, optionName)) return; // for inherit if (optionName === 'attributes') { subAttrNode[optionName] = {}; @@ -401,7 +400,7 @@ function processRequestOptions(req, attrNode, context) { ].join('') ); } - if (filteredAttrNode.filter.indexOf(filter.operator) === -1) { + if (!filteredAttrNode.filter.includes(filter.operator)) { throw new RequestError( [ 'Can not filter by attribute "' + filter.attribute.join('.') + '" ', @@ -438,7 +437,7 @@ function processRequestOptions(req, attrNode, context) { ); } - if (subFilter.filter.indexOf(filter.operator) === -1) { + if (!subFilter.filter.includes(filter.operator)) { throw new RequestError( `Can not filter by sub-resource attribute "${filter.attribute.join('.')}"` + (context.attrPath.length > 0 ? ` (in "${context.attrPath.join('.')}")` : '') + @@ -609,7 +608,7 @@ function processRequestOptions(req, attrNode, context) { ].join('') ); } - if (orderedAttrNode.order.indexOf(orderPart.direction) === -1) { + if (!orderedAttrNode.order.includes(orderPart.direction)) { throw new RequestError( [ 'Attribute "' + orderPart.attribute.join('.') + '" ', @@ -990,7 +989,7 @@ function resolveDataSourceOptions(resourceTree, dataSources, primaryName) { // TODO: Allow filter/order in different than the primary DataSource? - if (Object.prototype.hasOwnProperty.call(resourceTree, 'limit')) { + if (Object.hasOwn(resourceTree, 'limit')) { dataSources[primaryName].limit = resourceTree.limit; } @@ -1028,7 +1027,7 @@ function resolveResourceTree(resourceTree, parentDataSourceName) { let selectedDataSource = primaryName; const possibleDataSources = Object.keys(attrInfo.subResourceAttrNode.resolvedParentKey); - if (possibleDataSources.indexOf(selectedDataSource) === -1) { + if (!possibleDataSources.includes(selectedDataSource)) { // just select first possible one - optimize? selectedDataSource = possibleDataSources[0]; attrInfo.subResourceAttrNode.parentDataSource = selectedDataSource; diff --git a/lib/request.js b/lib/request.js index 8c2e459..b769167 100644 --- a/lib/request.js +++ b/lib/request.js @@ -113,7 +113,7 @@ class Request { // copy custom parameters Object.keys(options).forEach((key) => { - if (!Object.prototype.hasOwnProperty.call(this, key)) this[key] = options[key]; + if (!Object.hasOwn(this, key)) this[key] = options[key]; }); } } diff --git a/lib/resource-processor.js b/lib/resource-processor.js index 3f2c23c..4503f78 100644 --- a/lib/resource-processor.js +++ b/lib/resource-processor.js @@ -53,7 +53,7 @@ function explainDataSourceTree(dst, full) { ? operators[andFilter.operator](andFilter.value) : ' ' + andFilter.operator + ' ' + andFilter.value) + (andFilter.valueFromParentKey ? '{from-parent-key}' : '') + - (Object.prototype.hasOwnProperty.call(andFilter, 'valueFromSubFilter') + (Object.hasOwn(andFilter, 'valueFromSubFilter') ? `{from-sub-filter: ${andFilter.valueFromSubFilter}}` : '') ); @@ -163,8 +163,6 @@ class ResourceProcessor { * @return {Object} */ async handle(request, response) { - let resolvedDataSourceTree = null; - // Extension: "request" (resource) this.log.trace('handle: "request" extensions (resource)'); const resource = this.api.getResource(request.resource); @@ -186,6 +184,7 @@ class ResourceProcessor { // requestResolver this.log.trace('handle: requestResolver'); let resolved; + let resolvedDataSourceTree; profiler = request._profiler.child('requestResolver'); try { resolved = requestResolver(request, this.resourceConfigs); diff --git a/lib/result-builder.js b/lib/result-builder.js index 4767128..2d20377 100644 --- a/lib/result-builder.js +++ b/lib/result-builder.js @@ -193,7 +193,7 @@ function buildItem(parentAttrNode, row, context) { } else if (!attrNode.selected) return; if (attrNode.attributes) { - const subContext = Object.assign({}, context); + const subContext = { ...context }; subContext.attrPath = context.attrPath.concat([attrName]); if (attrNode.dataSources) { @@ -369,7 +369,7 @@ function buildItem(parentAttrNode, row, context) { }); } - const subContext = Object.assign({}, context); + const subContext = { ...context }; subContext.selectedInternalLevel++; if (subContext.selectedInternalLevel > maxRecursionLevel) { diff --git a/lib/server.js b/lib/server.js index bc5d357..31a0ce6 100644 --- a/lib/server.js +++ b/lib/server.js @@ -1,8 +1,8 @@ 'use strict'; -const http = require('http'); -const zlib = require('zlib'); -const Stream = require('stream'); +const http = require('node:http'); +const zlib = require('node:zlib'); +const Stream = require('node:stream'); const serveStatic = require('serve-static'); const ClusterWorker = require('@florajs/cluster').Worker; diff --git a/lib/url-parser.js b/lib/url-parser.js index d604b09..aaa39c9 100644 --- a/lib/url-parser.js +++ b/lib/url-parser.js @@ -1,25 +1,26 @@ 'use strict'; -const { URL } = require('url'); -const querystring = require('querystring'); +const { URL } = require('node:url'); +const { MIMEType } = require('node:util'); +const querystring = require('node:querystring'); const { RequestError } = require('@florajs/errors'); -const contentType = require('content-type'); const Request = require('./request'); /** * Map HTTP request into a Flora request * - * @param {http.IncomingRequest} httpRequest - * @param {Number} [options.timeout] Timeout when reading POST data (milliseconds) + * @param {http.IncomingMessage} httpRequest + * @param {Object} [options={}] + * @param {Number} [options.postTimeout] Timeout when reading POST data (milliseconds); no timeout if omitted * @returns {Promise} * @private */ function httpToFloraRequest(httpRequest, { postTimeout } = {}) { return new Promise((resolve, reject) => { const parsedUrl = new URL(httpRequest.url, 'http://localhost'); - const matches = parsedUrl.pathname.match(/^\/(.+)\/([^/.]*)(?:\.([a-z]+))?$/); + const matches = parsedUrl.pathname.match(/^\/(?.+)\/(?[^/.]*)(?:\.(?[a-z]+))?$/); if (!matches) { resolve(null); return; @@ -28,100 +29,92 @@ function httpToFloraRequest(httpRequest, { postTimeout } = {}) { /* * Gather GET parameters. */ + const { resource, id, format } = matches.groups; const opts = { - resource: matches[1], + resource, + ...(id && { id }), + ...(format && { format }), _auth: null, _status: httpRequest.flora.status, _httpRequest: httpRequest }; - if (matches[2]) opts.id = matches[2]; - if (matches[3]) opts.format = matches[3]; + for (const [param, value] of parsedUrl.searchParams) { + if (Object.hasOwn(opts, param)) continue; - parsedUrl.searchParams.forEach((value, param) => { - if (!Object.prototype.hasOwnProperty.call(opts, param)) { - if (parsedUrl.searchParams.getAll(param).length > 1) { - reject(new RequestError(`Duplicate parameter "${param}" in URL`)); - return; - } - - opts[param] = parsedUrl.searchParams.get(param); + if (parsedUrl.searchParams.getAll(param).length > 1) { + reject(new RequestError(`Duplicate parameter "${param}" in URL`)); + return; } - }); + + opts[param] = value; + } /* * Handle POST payload. */ - httpRequest.on('error', (err) => reject(new RequestError('Error reading HTTP-Request: ' + err.message))); + httpRequest.once('error', (err) => reject(new RequestError('Error reading HTTP-Request: ' + err.message))); if (httpRequest.method === 'POST' && Number(httpRequest.headers['content-length']) > 0) { let payload = ''; - let contentTypes; - - if (httpRequest.headers['content-type']) { - try { - contentTypes = contentType.parse(httpRequest.headers['content-type']); - } catch (e) { - reject(new RequestError('Error parsing Content-Type header: ' + e.message)); - return; - } - } else { + + if (!httpRequest.headers['content-type']) { reject(new RequestError('Missing required Content-Type headers')); return; } - if (contentTypes.type === 'application/json' || contentTypes.type === 'application/x-www-form-urlencoded') { - let timeout; + let contentType; + try { + contentType = new MIMEType(httpRequest.headers['content-type']); + } catch (err) { + reject(new RequestError('Error parsing Content-Type header', { cause: err })); + return; + } + + if ( + contentType.essence === 'application/json' || + contentType.essence === 'application/x-www-form-urlencoded' + ) { if (postTimeout) { - timeout = setTimeout(() => { - reject(new RequestError('Timeout reading POST data')); - }, postTimeout); + const timeout = setTimeout( + () => reject(new RequestError('Timeout reading POST data')), + postTimeout + ); + + // clean up the timeout once the request is done, regardless of outcome. + httpRequest.once('close', () => clearTimeout(timeout)); } if (httpRequest.flora) httpRequest.flora.state = 'processing-post-data'; // POST Form Data or JSON - httpRequest.setEncoding(contentTypes.parameters.charset || 'utf-8'); + httpRequest.setEncoding(contentType.params.get('charset') || 'utf-8'); httpRequest.on('data', (chunk) => { payload += chunk; }); - httpRequest.on('aborted', () => { - if (timeout) { - clearTimeout(timeout); - timeout = null; - } - reject(new RequestError('HTTP request has been aborted')); - }); + httpRequest.once('aborted', () => reject(new RequestError('HTTP request has been aborted'))); - httpRequest.on('end', () => { - if (contentTypes.type === 'application/x-www-form-urlencoded') { + httpRequest.once('end', () => { + if (contentType.essence === 'application/x-www-form-urlencoded') { payload = querystring.parse(payload); - Object.keys(payload).forEach((key) => { - if (!Object.prototype.hasOwnProperty.call(opts, key)) { - if (Array.isArray(payload[key])) { - if (httpRequest.flora) httpRequest.flora.state = 'processing'; - if (timeout) { - clearTimeout(timeout); - timeout = null; - } - reject(new RequestError(`Duplicate parameter "${key}" in Payload`)); - return; - } - - opts[key] = payload[key]; + for (const [key, value] of Object.entries(payload)) { + if (Object.hasOwn(opts, key)) continue; + + if (Array.isArray(value)) { + if (httpRequest.flora) httpRequest.flora.state = 'processing'; + reject(new RequestError(`Duplicate parameter "${key}" in Payload`)); + return; } - }); + + opts[key] = value; + } if (!httpRequest.body) httpRequest.body = payload; - } else if (contentTypes.type === 'application/json') { + } else if (contentType.essence === 'application/json') { try { opts.data = JSON.parse(payload); } catch { if (httpRequest.flora) httpRequest.flora.state = 'processing'; - if (timeout) { - clearTimeout(timeout); - timeout = null; - } reject(new RequestError('Invalid payload, must be valid JSON')); return; } @@ -129,10 +122,6 @@ function httpToFloraRequest(httpRequest, { postTimeout } = {}) { } if (httpRequest.flora) httpRequest.flora.state = 'processing'; - if (timeout) { - clearTimeout(timeout); - timeout = null; - } resolve(new Request(opts)); }); } else { diff --git a/lib/xml-reader.js b/lib/xml-reader.js index 0f89b57..8e5afd0 100644 --- a/lib/xml-reader.js +++ b/lib/xml-reader.js @@ -1,6 +1,6 @@ 'use strict'; -const fs = require('fs'); +const fs = require('node:fs'); const { DOMParser } = require('@xmldom/xmldom'); const { ImplementationError } = require('@florajs/errors'); @@ -34,10 +34,7 @@ function copyXmlAttributes(node) { return Array.from(node.attributes) .filter((attr) => !attr.prefix) - .reduce((cfg, attr) => { - cfg[attr.localName] = attr.value; - return cfg; - }, {}); + .reduce((cfg, attr) => ({ ...cfg, [attr.localName]: attr.value }), {}); } function _filterFloraOptionNodes(node) { @@ -124,12 +121,12 @@ function _parseSubFilterNode(cfg, node) { function parse(node) { const childNodes = Array.from(node.childNodes); - childNodes - .filter((node) => node.nodeType === TEXT_NODE) - .filter((node) => node.textContent.trim().length > 0) - .forEach((node) => { - throw new ImplementationError(`Config contains unnecessary text: "${node.textContent.trim()}"`); - }); + const uselessTextNode = childNodes.find( + (node) => node.nodeType === TEXT_NODE && node.textContent.trim().length > 0 + ); + if (uselessTextNode) { + throw new ImplementationError(`Config contains unnecessary text: "${uselessTextNode.textContent.trim()}"`); + } const elementNodes = childNodes.filter((node) => node.nodeType === ELEMENT_NODE); const floraNodes = elementNodes.filter((node) => node.namespaceURI === 'urn:flora:options'); diff --git a/package.json b/package.json index e1441cd..264de65 100644 --- a/package.json +++ b/package.json @@ -42,28 +42,28 @@ } ], "engines": { - "node": ">=18" + "node": ">=22" }, "dependencies": { "@florajs/cluster": "^4.0.2", "@florajs/errors": "^4.0.0", "@florajs/request-parser": "^5.0.1", - "@xmldom/xmldom": "^0.9.8", + "@xmldom/xmldom": "^0.9.10", "bunyan": "^1.8.15", - "chokidar": "^4.0.3", - "content-type": "^1.0.5", - "luxon": "^3.6.1", + "chokidar": "^5.0.0", + "luxon": "^3.7.2", "promise-events": "^0.2.4", - "serve-static": "^1.16.2" + "serve-static": "^2.2.1" }, "devDependencies": { + "@eslint/js": "^10.0.1", "abstract-logging": "^2.0.1", - "eslint": "^9.25.1", - "eslint-config-prettier": "^10.1.2", - "eslint-plugin-prettier": "^5.2.6", - "globals": "^16.0.0", - "jsdoc": "^4.0.4", + "eslint": "^10.8.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-prettier": "^5.5.6", + "globals": "^17.9.0", + "jsdoc": "^4.0.5", "mock-fs": "^5.5.0", - "prettier": "^3.5.3" + "prettier": "^3.9.6" } } diff --git a/test/config-loader.spec.js b/test/config-loader.spec.js index 60e7d33..7594ccb 100644 --- a/test/config-loader.spec.js +++ b/test/config-loader.spec.js @@ -6,6 +6,7 @@ const assert = require('node:assert/strict'); const fsMock = require('mock-fs'); // const sinon = require('sinon'); const nullLogger = require('abstract-logging'); +const { ImplementationError } = require('@florajs/errors'); const configLoader = require('../lib/config-loader'); @@ -22,11 +23,11 @@ function parseXml(/* file */) { describe('config-loader', () => { it('should issue an error if config directory does not exist', async () => { - const directory = require('path').resolve('nonexistent-directory'); + const directory = require('node:path').resolve('nonexistent-directory'); await assert.rejects( configLoader(api, { directory }), - new Error(`Config directory "${directory}" does not exist`) + new ImplementationError(`Cannot access config directory "${directory}"`) ); }); @@ -147,7 +148,7 @@ describe('config-loader', () => { parsers: { xml: parseXml } }; - await assert.rejects(configLoader(api, cfg), new Error('No "json" config parser registered')); + await assert.rejects(configLoader(api, cfg), new ImplementationError('No "json" config parser registered')); }); it('should register additional loaders', async () => { diff --git a/test/extensions.spec.js b/test/extensions.spec.js index 16a13f8..3799f0f 100644 --- a/test/extensions.spec.js +++ b/test/extensions.spec.js @@ -3,7 +3,7 @@ const { describe, it } = require('node:test'); const assert = require('node:assert/strict'); const path = require('node:path'); -const { once } = require('events'); +const { once } = require('node:events'); const nullLogger = require('abstract-logging'); diff --git a/test/url-parser.spec.js b/test/url-parser.spec.js index 33bbcc3..6df583d 100644 --- a/test/url-parser.spec.js +++ b/test/url-parser.spec.js @@ -1,34 +1,31 @@ 'use strict'; -const { describe, it, beforeEach } = require('node:test'); +const { Readable } = require('node:stream'); +const http = require('node:http'); +const { describe, it, beforeEach, afterEach } = require('node:test'); const assert = require('node:assert/strict'); const parseRequest = require('../lib/url-parser'); +/** + * Build a request stream. Without a body, the stream never ends (useful for + * timeout tests). A string body is delivered as a single chunk; an array of + * chunks (e.g. `[...body]` for one chunk per character) forces the consumer + * to read and reassemble the body across multiple reads. + */ +function createRequest({ method = 'GET', headers = {}, body } = {}) { + const req = body === undefined ? new Readable({ read() {} }) : Readable.from(body); + req.method = method; + req.headers = headers; + req.flora = { status: {} }; + return req; +} + describe('HTTP request parsing', () => { let httpRequest; beforeEach(() => { - let dataFn; - - httpRequest = { - flora: { status: {} }, - method: 'GET', - headers: { 'content-type': 'application/json' }, - payload: null, - setEncoding() {}, - on(e, fn) { - if (e === 'data') dataFn = fn; - if (e === 'end') { - if (httpRequest.payload) { - for (let char of httpRequest.payload) { - setTimeout(() => dataFn(char), 0); - } - } - setTimeout(() => fn(), 0); - } - } - }; + httpRequest = createRequest({ headers: { 'content-type': 'application/json' } }); }); it('should return promise', () => { @@ -140,12 +137,15 @@ describe('HTTP request parsing', () => { describe('POST payload', () => { it('should parse JSON payload', async () => { - httpRequest.url = 'http://api.example.com/user/'; - httpRequest.payload = '{"a": true}'; - httpRequest.method = 'POST'; - httpRequest.headers['content-length'] = httpRequest.payload.length; + const body = '{"a":true}'; + const req = createRequest({ + method: 'POST', + headers: { 'content-type': 'application/json', 'content-length': body.length }, + body: [...body] + }); + req.url = 'http://api.example.com/user/'; - const request = await parseRequest(httpRequest); + const request = await parseRequest(req); assert.ok(Object.hasOwn(request, 'data')); assert.ok(Object.hasOwn(request.data, 'a')); @@ -158,13 +158,15 @@ describe('HTTP request parsing', () => { }); it('should parse form-urlencoded payload', async () => { - httpRequest.url = 'http://api.example.com/user/'; - httpRequest.headers['content-type'] = 'application/x-www-form-urlencoded'; - httpRequest.payload = 'a=true&b=false'; - httpRequest.method = 'POST'; - httpRequest.headers['content-length'] = httpRequest.payload.length; + const body = 'a=true&b=false'; + const req = createRequest({ + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded', 'content-length': body.length }, + body: [...body] + }); + req.url = 'http://api.example.com/user/'; - const request = await parseRequest(httpRequest); + const request = await parseRequest(req); assert.ok(Object.hasOwn(request, 'data')); assert.ok(Object.hasOwn(request, 'a')); @@ -180,25 +182,112 @@ describe('HTTP request parsing', () => { assert.equal(request._httpRequest.body.b, 'false'); }); + it('should parse a payload delivered asynchronously across multiple ticks', async () => { + const body = '{"a":true}'; + + async function* delayedChunks() { + for (const char of body) { + await new Promise((resolve) => setTimeout(resolve, 1)); + yield char; + } + } + + const req = createRequest({ + method: 'POST', + headers: { 'content-type': 'application/json', 'content-length': body.length }, + body: delayedChunks() + }); + req.url = 'http://api.example.com/user/'; + + const request = await parseRequest(req, { postTimeout: 1000 }); + + assert.equal(request.data.a, true); + }); + + [ + { + description: 'should reject POST with malformed Content-Type header', + mutate: (headers) => (headers['content-type'] = ';;;not a valid content type;;;'), + message: 'Error parsing Content-Type header' + }, + { + description: 'should reject POST with missing Content-Type header', + mutate: (headers) => delete headers['content-type'], + message: 'Missing required Content-Type headers' + }, + { + description: 'should reject POST with empty Content-Type header', + mutate: (headers) => (headers['content-type'] = ''), + message: 'Missing required Content-Type headers' + } + ].forEach(({ description, mutate, message }) => { + it(description, async () => { + const body = '{"a":true}'; + const headers = { 'content-type': 'application/json', 'content-length': body.length }; + mutate(headers); + + const req = createRequest({ method: 'POST', headers, body: [...body] }); + req.url = 'http://api.example.com/user/'; + + await assert.rejects(parseRequest(req), { + name: 'RequestError', + message + }); + }); + }); + it('should time out after postTimeout', async () => { - const slowRequest = { - flora: { status: {} }, + const slowRequest = createRequest({ method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded', 'content-length': 1000 - }, - url: '/user/', - payload: null, - setEncoding() {}, - on() {} - }; + } + // no body -> stream never ends -> postTimeout must fire + }); + slowRequest.url = '/user/'; await assert.rejects(parseRequest(slowRequest, { postTimeout: 10 }), { message: 'Timeout reading POST data' }); }); + it('should clear the postTimeout timer once the request completes', async (ctx) => { + const req = createRequest({ + method: 'POST', + headers: { 'content-type': 'application/json', 'content-length': '10' }, + body: '{"a":true}' + }); + req.url = '/user/'; + + const clearTimeoutSpy = ctx.mock.method(global, 'clearTimeout'); + await parseRequest(req, { postTimeout: 1000 }); + + assert.equal(clearTimeoutSpy.mock.callCount(), 1); + }); + + it('should reject if the request stream emits an error', async (ctx) => { + const req = createRequest({ + method: 'POST', + headers: { + 'content-type': 'application/x-www-form-urlencoded', + 'content-length': 1000 + } + // no body -> stream stays open until destroyed below + }); + req.url = '/user/'; + + const clearTimeoutSpy = ctx.mock.method(global, 'clearTimeout'); + const pending = parseRequest(req, { postTimeout: 1000 }); + req.destroy(new Error('socket hang up')); + + await assert.rejects(pending, { + name: 'RequestError', + message: 'Error reading HTTP-Request: socket hang up' + }); + assert.equal(clearTimeoutSpy.mock.callCount(), 1); + }); + it('should remove protected properties (GET)', async () => { httpRequest.url = 'http://api.example.com/user/1337.jpg?_auth=FOO'; const request = await parseRequest(httpRequest); @@ -208,28 +297,158 @@ describe('HTTP request parsing', () => { }); it('should remove protected properties (urlencoded)', async () => { - httpRequest.url = 'http://api.example.com/user/'; - httpRequest.headers['content-type'] = 'application/x-www-form-urlencoded'; - httpRequest.payload = '_auth=FOO'; - httpRequest.method = 'POST'; - httpRequest.headers['content-length'] = httpRequest.payload.length; + const body = '_auth=FOO'; + const req = createRequest({ + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded', 'content-length': body.length }, + body: [...body] + }); + req.url = 'http://api.example.com/user/'; - const request = await parseRequest(httpRequest); + const request = await parseRequest(req); assert.ok(Object.hasOwn(request, '_auth')); assert.equal(request._auth, null); }); it('should remove protected properties (JSON)', async () => { - httpRequest.url = 'http://api.example.com/user/'; - httpRequest.payload = '{"_auth": "FOO"}'; - httpRequest.method = 'POST'; - httpRequest.headers['content-length'] = httpRequest.payload.length; + const body = '{"_auth":"FOO"}'; + const req = createRequest({ + method: 'POST', + headers: { 'content-type': 'application/json', 'content-length': body.length }, + body: [...body] + }); + req.url = 'http://api.example.com/user/'; - const request = await parseRequest(httpRequest); + const request = await parseRequest(req); assert.ok(Object.hasOwn(request, '_auth')); assert.equal(request._auth, null); }); }); + + describe('real requests', () => { + let httpServer; + + /** + * @param {function(http.IncomingMessage, http.ServerResponse): Promise} onRequest - + * Called for each incoming request with the request (its `flora` property already set) + * and the response + * @returns {Promise} The port the server is listening on + */ + function startServer(onRequest) { + return new Promise((resolve, reject) => { + httpServer = http.createServer((req, res) => { + req.flora = { status: {} }; + onRequest(req, res); + }); + httpServer.once('error', reject); + httpServer.listen(0, () => resolve(httpServer.address().port)); + }); + } + + afterEach(() => new Promise((resolve) => (httpServer ? httpServer.close(resolve) : resolve()))); + + it('should parse a real GET request', async () => { + const port = await startServer((req, res) => { + parseRequest(req).then( + (request) => + res.end(JSON.stringify({ ok: true, resource: request.resource, width: request.width })), + (err) => res.end(JSON.stringify({ ok: false, message: err.message })) + ); + }); + + // Connection: close tells the server to drop the socket once the + // response is sent, instead of keeping it open for reuse - so + // afterEach's server.close() doesn't have to wait it out. + const response = await fetch(`http://127.0.0.1:${port}/user/1337.jpg?width=60`, { + headers: { connection: 'close' } + }); + const body = await response.json(); + + assert.deepEqual(body, { ok: true, resource: 'user', width: '60' }); + }); + + it('should parse a real POST request with a JSON body', async () => { + const port = await startServer((req, res) => { + parseRequest(req).then( + (request) => res.end(JSON.stringify({ ok: true, data: request.data })), + (err) => res.end(JSON.stringify({ ok: false, message: err.message })) + ); + }); + + const response = await fetch(`http://127.0.0.1:${port}/user/`, { + method: 'POST', + headers: { 'content-type': 'application/json', connection: 'close' }, + body: JSON.stringify({ a: true }) + }); + const body = await response.json(); + + assert.deepEqual(body, { ok: true, data: { a: true } }); + }); + + it('should reject with "HTTP request has been aborted" if the client disconnects mid-body', async (ctx) => { + const { promise: result, resolve, reject } = Promise.withResolvers(); + + const port = await startServer((req) => { + parseRequest(req, { postTimeout: 1000 }).then(resolve, reject); + }); + + const clearTimeoutSpy = ctx.mock.method(global, 'clearTimeout'); + const controller = new AbortController(); + fetch(`http://127.0.0.1:${port}/user/`, { + method: 'POST', + headers: { 'content-type': 'application/json', 'content-length': '1000', connection: 'close' }, + // a streaming body keeps the request open until aborted below; a fixed + // content-length (not chunked transfer-encoding) is required for the + // server to enter its body-reading branch at all + body: new ReadableStream({ + start(streamController) { + streamController.enqueue(new TextEncoder().encode('{"partial":')); + } + }), + duplex: 'half', + signal: controller.signal + }).catch(() => {}); // aborting rejects the fetch itself; only the server-side outcome matters here + + // give the server a moment to receive the partial body before severing the connection + await new Promise((resolve) => setTimeout(resolve, 100)); + controller.abort(); + + await assert.rejects(result, { + name: 'RequestError', + message: 'HTTP request has been aborted' + }); + assert.equal(clearTimeoutSpy.mock.callCount(), 1); + }); + + it('should time out a real request whose body never completes', async () => { + const { promise: result, resolve, reject } = Promise.withResolvers(); + + const port = await startServer((req) => { + parseRequest(req, { postTimeout: 50 }).then(resolve, reject); + }); + + const controller = new AbortController(); + fetch(`http://127.0.0.1:${port}/user/`, { + method: 'POST', + headers: { 'content-type': 'application/json', 'content-length': '1000', connection: 'close' }, + body: new ReadableStream({ + start(streamController) { + streamController.enqueue(new TextEncoder().encode('{"partial":')); + // never close -> the server keeps waiting until postTimeout fires + } + }), + duplex: 'half', + signal: controller.signal + }).catch(() => {}); // no response is ever sent; aborted below once the assertion is done + + await assert.rejects(result, { + name: 'RequestError', + message: 'Timeout reading POST data' + }); + + controller.abort(); + }); + }); });