From cac59cf8474662c64f60d5d148708adb4f7452d0 Mon Sep 17 00:00:00 2001 From: Marco Baumgartl Date: Fri, 7 Aug 2026 09:45:12 +0200 Subject: [PATCH 01/30] BREAKING: require node>=22, bump ecmaVersion to 2023 --- .github/workflows/ci.yml | 2 +- eslint.config.js | 2 +- package.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f031dad..3191804 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'] 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/package.json b/package.json index e1441cd..e3b93cd 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,7 @@ } ], "engines": { - "node": ">=18" + "node": ">=22" }, "dependencies": { "@florajs/cluster": "^4.0.2", From 65dbb95f2f8fa0c3dc611c4346f513c56eb0cc2e Mon Sep 17 00:00:00 2001 From: Marco Baumgartl Date: Fri, 7 Aug 2026 09:47:46 +0200 Subject: [PATCH 02/30] ci: test against node 24 and 26 --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3191804..6c72b73 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - node: ['22'] + node: ['22', '24', '26'] steps: - uses: actions/checkout@v7 - name: Setup node ${{ matrix.node }} From 65780d21851325830524495607936c2f94ed1348 Mon Sep 17 00:00:00 2001 From: Marco Baumgartl Date: Fri, 7 Aug 2026 10:08:00 +0200 Subject: [PATCH 03/30] refactor: replace hasOwnProperty.call() with Object.hasOwn() --- examples/express/index.js | 2 +- lib/api.js | 7 +++---- lib/cast.js | 2 +- lib/datasource-executor.js | 8 ++++---- lib/request-resolver.js | 7 +++---- lib/request.js | 2 +- lib/resource-processor.js | 2 +- lib/url-parser.js | 4 ++-- 8 files changed, 16 insertions(+), 18 deletions(-) diff --git a/examples/express/index.js b/examples/express/index.js index abe6bbd..3dee07c 100644 --- a/examples/express/index.js +++ b/examples/express/index.js @@ -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/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/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/datasource-executor.js b/lib/datasource-executor.js index 16c3604..8836882 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; } @@ -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) { diff --git a/lib/request-resolver.js b/lib/request-resolver.js index 14c0cec..21af680 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] = {}; @@ -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; } 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..55a46c6 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}}` : '') ); diff --git a/lib/url-parser.js b/lib/url-parser.js index d604b09..bee3a93 100644 --- a/lib/url-parser.js +++ b/lib/url-parser.js @@ -39,7 +39,7 @@ function httpToFloraRequest(httpRequest, { postTimeout } = {}) { 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) { reject(new RequestError(`Duplicate parameter "${param}" in URL`)); return; @@ -98,7 +98,7 @@ function httpToFloraRequest(httpRequest, { postTimeout } = {}) { if (contentTypes.type === 'application/x-www-form-urlencoded') { payload = querystring.parse(payload); Object.keys(payload).forEach((key) => { - if (!Object.prototype.hasOwnProperty.call(opts, key)) { + if (!Object.hasOwn(opts, key)) { if (Array.isArray(payload[key])) { if (httpRequest.flora) httpRequest.flora.state = 'processing'; if (timeout) { From 8e98a80657395fd3a99c229c4f52ab6dcdb14545 Mon Sep 17 00:00:00 2001 From: Marco Baumgartl Date: Fri, 7 Aug 2026 14:28:07 +0200 Subject: [PATCH 04/30] chore: update eslint and related dependencies --- package.json | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index e3b93cd..b67ed9b 100644 --- a/package.json +++ b/package.json @@ -57,10 +57,11 @@ "serve-static": "^1.16.2" }, "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", + "eslint": "^10.8.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-prettier": "^5.5.6", "globals": "^16.0.0", "jsdoc": "^4.0.4", "mock-fs": "^5.5.0", From 2ed1266855c2f165d62f50178141eb059afac009 Mon Sep 17 00:00:00 2001 From: Marco Baumgartl Date: Fri, 7 Aug 2026 14:28:29 +0200 Subject: [PATCH 05/30] fix: remove useless variable assignments flagged by eslint --- lib/ascii-art-profile.js | 10 ++++------ lib/resource-processor.js | 3 +-- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/lib/ascii-art-profile.js b/lib/ascii-art-profile.js index 1333139..49de8de 100644 --- a/lib/ascii-art-profile.js +++ b/lib/ascii-art-profile.js @@ -12,14 +12,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 +27,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 +49,6 @@ module.exports = function asciiArtProfile(profile, totalDuration, width) { } else { durationWidth = width - beforeWidth; durationChar = '?'; - afterWidth = 0; description = ' (' + measure.name + ' - still running!)'; } diff --git a/lib/resource-processor.js b/lib/resource-processor.js index 55a46c6..4503f78 100644 --- a/lib/resource-processor.js +++ b/lib/resource-processor.js @@ -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); From d8253c974052786627221166b4a7ffb04f598005 Mon Sep 17 00:00:00 2001 From: Marco Baumgartl Date: Fri, 7 Aug 2026 14:58:06 +0200 Subject: [PATCH 06/30] refactor: re-reject requests with malformed Content-Type header content-type@2 no longer throws on malformed Content-Type headers, so the previous try/catch became dead code (also untested). Since Flora should still reject a malformed header (unlike a valid-but-unsupported one, e.g. "text/plain", which is left untouched), the check is now done explicitly instead of relying on parse() throwing. --- lib/url-parser.js | 16 +++++++--------- package.json | 2 +- test/url-parser.spec.js | 31 +++++++++++++++++++++++++++++++ 3 files changed, 39 insertions(+), 10 deletions(-) diff --git a/lib/url-parser.js b/lib/url-parser.js index bee3a93..b5110bf 100644 --- a/lib/url-parser.js +++ b/lib/url-parser.js @@ -56,20 +56,18 @@ function httpToFloraRequest(httpRequest, { postTimeout } = {}) { 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; } + const contentTypes = contentType.parse(httpRequest.headers['content-type']); + if (!contentTypes.type) { + reject(new RequestError('Error parsing Content-Type header: invalid media type')); + return; + } + if (contentTypes.type === 'application/json' || contentTypes.type === 'application/x-www-form-urlencoded') { let timeout; if (postTimeout) { diff --git a/package.json b/package.json index b67ed9b..3eabcf5 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,7 @@ "@xmldom/xmldom": "^0.9.8", "bunyan": "^1.8.15", "chokidar": "^4.0.3", - "content-type": "^1.0.5", + "content-type": "^2.0.0", "luxon": "^3.6.1", "promise-events": "^0.2.4", "serve-static": "^1.16.2" diff --git a/test/url-parser.spec.js b/test/url-parser.spec.js index 33bbcc3..b2be74a 100644 --- a/test/url-parser.spec.js +++ b/test/url-parser.spec.js @@ -180,6 +180,37 @@ describe('HTTP request parsing', () => { assert.equal(request._httpRequest.body.b, 'false'); }); + [ + { + 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: invalid media type' + }, + { + 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 () => { + httpRequest.url = 'http://api.example.com/user/'; + httpRequest.payload = '{"a": true}'; + httpRequest.method = 'POST'; + httpRequest.headers['content-length'] = httpRequest.payload.length; + mutate(httpRequest.headers); + + await assert.rejects(parseRequest(httpRequest), { + name: 'RequestError', + message + }); + }); + }); + it('should time out after postTimeout', async () => { const slowRequest = { flora: { status: {} }, From 7ec6c70bb338030b64f0473a1cd08de86a212493 Mon Sep 17 00:00:00 2001 From: Marco Baumgartl Date: Fri, 7 Aug 2026 15:34:57 +0200 Subject: [PATCH 07/30] chore: update dependencies --- package.json | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index 3eabcf5..264d0e0 100644 --- a/package.json +++ b/package.json @@ -48,13 +48,13 @@ "@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", + "chokidar": "^5.0.0", "content-type": "^2.0.0", - "luxon": "^3.6.1", + "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", @@ -62,9 +62,9 @@ "eslint": "^10.8.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-prettier": "^5.5.6", - "globals": "^16.0.0", - "jsdoc": "^4.0.4", + "globals": "^17.9.0", + "jsdoc": "^4.0.5", "mock-fs": "^5.5.0", - "prettier": "^3.5.3" + "prettier": "^3.9.6" } } From ac51046a4e12e00ed57f317bc359577051e6af07 Mon Sep 17 00:00:00 2001 From: Marco Baumgartl Date: Mon, 10 Aug 2026 15:59:06 +0200 Subject: [PATCH 08/30] refactor: replace content-type dependency with node:util's MIMEType Node's built-in MIMEType/MIMEParams (available since Node 22, the project's minimum supported version) implements the same WHATWG media-type parsing as the content-type package, so the dependency can be dropped. --- lib/url-parser.js | 21 +++++++++++++-------- package.json | 1 - test/url-parser.spec.js | 2 +- 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/lib/url-parser.js b/lib/url-parser.js index b5110bf..113e872 100644 --- a/lib/url-parser.js +++ b/lib/url-parser.js @@ -1,10 +1,10 @@ 'use strict'; const { URL } = require('url'); +const { MIMEType } = require('node:util'); const querystring = require('querystring'); const { RequestError } = require('@florajs/errors'); -const contentType = require('content-type'); const Request = require('./request'); @@ -62,13 +62,18 @@ function httpToFloraRequest(httpRequest, { postTimeout } = {}) { return; } - const contentTypes = contentType.parse(httpRequest.headers['content-type']); - if (!contentTypes.type) { - reject(new RequestError('Error parsing Content-Type header: invalid media type')); + let contentType; + try { + contentType = new MIMEType(httpRequest.headers['content-type']); + } catch (err) { + reject(new RequestError('Error parsing Content-Type header', { cause: err })); return; } - if (contentTypes.type === 'application/json' || contentTypes.type === 'application/x-www-form-urlencoded') { + if ( + contentType.essence === 'application/json' || + contentType.essence === 'application/x-www-form-urlencoded' + ) { let timeout; if (postTimeout) { timeout = setTimeout(() => { @@ -79,7 +84,7 @@ function httpToFloraRequest(httpRequest, { postTimeout } = {}) { 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; }); @@ -93,7 +98,7 @@ function httpToFloraRequest(httpRequest, { postTimeout } = {}) { }); httpRequest.on('end', () => { - if (contentTypes.type === 'application/x-www-form-urlencoded') { + if (contentType.essence === 'application/x-www-form-urlencoded') { payload = querystring.parse(payload); Object.keys(payload).forEach((key) => { if (!Object.hasOwn(opts, key)) { @@ -111,7 +116,7 @@ function httpToFloraRequest(httpRequest, { postTimeout } = {}) { } }); 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 { diff --git a/package.json b/package.json index 264d0e0..264de65 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,6 @@ "@xmldom/xmldom": "^0.9.10", "bunyan": "^1.8.15", "chokidar": "^5.0.0", - "content-type": "^2.0.0", "luxon": "^3.7.2", "promise-events": "^0.2.4", "serve-static": "^2.2.1" diff --git a/test/url-parser.spec.js b/test/url-parser.spec.js index b2be74a..e993fef 100644 --- a/test/url-parser.spec.js +++ b/test/url-parser.spec.js @@ -184,7 +184,7 @@ describe('HTTP request parsing', () => { { 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: invalid media type' + message: 'Error parsing Content-Type header' }, { description: 'should reject POST with missing Content-Type header', From 4fd0eda7452e1b9e7880fc72fd1fabaeb84f306e Mon Sep 17 00:00:00 2001 From: Marco Baumgartl Date: Mon, 10 Aug 2026 16:03:04 +0200 Subject: [PATCH 09/30] refactor: prefix core Node module requires with "node:" Follows the node: scheme already used for e.g. node:test/node:assert in the test suite, making all core module imports consistently distinguishable from third-party and local requires. --- examples/docker/config.js | 2 +- examples/docker/worker.js | 2 +- examples/express/README.md | 2 +- examples/express/index.js | 2 +- examples/extensions/config.js | 2 +- examples/extensions/server.js | 2 +- examples/native/native.js | 2 +- examples/plugins/master.js | 2 +- examples/plugins/worker.js | 2 +- examples/simple/config.js | 2 +- examples/simple/master.js | 2 +- examples/simple/worker.js | 2 +- lib/config-loader.js | 2 +- lib/master.js | 2 +- lib/server.js | 6 +++--- lib/url-parser.js | 4 ++-- lib/xml-reader.js | 2 +- test/config-loader.spec.js | 2 +- test/extensions.spec.js | 2 +- 19 files changed, 22 insertions(+), 22 deletions(-) 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 3dee07c..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) { 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/config-loader.js b/lib/config-loader.js index fb614c1..b483550 100644 --- a/lib/config-loader.js +++ b/lib/config-loader.js @@ -1,7 +1,7 @@ 'use strict'; const fs = require('node:fs/promises'); -const path = require('path'); +const path = require('node:path'); /** * Read config files from directory recursively. 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/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 113e872..0de8e77 100644 --- a/lib/url-parser.js +++ b/lib/url-parser.js @@ -1,8 +1,8 @@ 'use strict'; -const { URL } = require('url'); +const { URL } = require('node:url'); const { MIMEType } = require('node:util'); -const querystring = require('querystring'); +const querystring = require('node:querystring'); const { RequestError } = require('@florajs/errors'); diff --git a/lib/xml-reader.js b/lib/xml-reader.js index 0f89b57..3d92e10 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'); diff --git a/test/config-loader.spec.js b/test/config-loader.spec.js index 60e7d33..1060514 100644 --- a/test/config-loader.spec.js +++ b/test/config-loader.spec.js @@ -22,7 +22,7 @@ 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 }), 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'); From 64f3adf149bd56ce16e554271c32d911dff2aca7 Mon Sep 17 00:00:00 2001 From: Marco Baumgartl Date: Mon, 10 Aug 2026 16:26:36 +0200 Subject: [PATCH 10/30] refactor: replace manual directory walk with fs.glob in config-loader The recursive walk() (readdir + stat per entry) is replaced by a single fs.glob('*/**/{config.*,index.js}') call, which handles the recursion and file-type filtering natively. Since walk() had only one call site and is now just a short loop, it's inlined into configLoader instead of kept as a separate function. --- lib/config-loader.js | 51 +++++++++----------------------------------- 1 file changed, 10 insertions(+), 41 deletions(-) diff --git a/lib/config-loader.js b/lib/config-loader.js index b483550..c0eb4cf 100644 --- a/lib/config-loader.js +++ b/lib/config-loader.js @@ -3,40 +3,6 @@ const fs = require('node:fs/promises'); 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; -} - /** * Load resource configs from config directory. * @@ -53,8 +19,6 @@ module.exports = async function configLoader(api, options) { }, ...options }; - let resources; - const configDirectory = path.resolve(cfg.directory); const configParsers = cfg.parsers; @@ -67,11 +31,16 @@ module.exports = async function configLoader(api, options) { throw new Error(`Config directory "${configDirectory}" does not exist`); } - try { - resources = await walk(configDirectory); - } catch (err) { - err.message = 'Error reading resource directory tree: ' + err.message; - throw err; + 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); + + resources[resourceName] ??= {}; + if (entry.name.startsWith('config.')) resources[resourceName].configFile = absoluteFilePath; + if (entry.name === 'index.js') resources[resourceName].instanceFile = absoluteFilePath; } // parse all configs From 3a9ae9837c16bf2397cc2df3eb1c784a24bbd494 Mon Sep 17 00:00:00 2001 From: Marco Baumgartl Date: Mon, 10 Aug 2026 16:40:52 +0200 Subject: [PATCH 11/30] refactor: parse resource configs while walking config directory Config parsing is now done inline as each config.* file is discovered by the fs.glob loop, instead of in a separate Promise.all pass over the collected resources afterwards. This drops the intermediate configFile bookkeeping property, at the cost of parsing configs sequentially instead of concurrently. Errors are now raised as ImplementationError (consistent with the rest of lib/), wrapping the original parser error via `cause` instead of rewriting its message. --- lib/config-loader.js | 33 +++++++++++++-------------------- test/config-loader.spec.js | 3 ++- 2 files changed, 15 insertions(+), 21 deletions(-) diff --git a/lib/config-loader.js b/lib/config-loader.js index c0eb4cf..9a4946c 100644 --- a/lib/config-loader.js +++ b/lib/config-loader.js @@ -3,6 +3,8 @@ const fs = require('node:fs/promises'); const path = require('node:path'); +const { ImplementationError } = require('@florajs/errors'); + /** * Load resource configs from config directory. * @@ -39,32 +41,23 @@ module.exports = async function configLoader(api, options) { const absoluteFilePath = path.join(entry.parentPath, entry.name); resources[resourceName] ??= {}; - if (entry.name.startsWith('config.')) resources[resourceName].configFile = absoluteFilePath; - if (entry.name === 'index.js') resources[resourceName].instanceFile = absoluteFilePath; - } - - // parse all configs - await Promise.all( - Object.keys(resources).map(async (resourceName) => { - const file = resources[resourceName].configFile; - if (!file) return null; - const extension = path.extname(file); - const type = extension.substring(1); + 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 }); } - }) - ); + } + + if (entry.name === 'index.js') resources[resourceName].instanceFile = absoluteFilePath; + } // load all resources await Promise.all( diff --git a/test/config-loader.spec.js b/test/config-loader.spec.js index 1060514..182a874 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'); @@ -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 () => { From f25a90eb3a6e9f427874ed5ff96120bf39511eec Mon Sep 17 00:00:00 2001 From: Marco Baumgartl Date: Mon, 10 Aug 2026 16:47:57 +0200 Subject: [PATCH 12/30] refactor: load resource instances while walking config directory Loading a resource's index.js is now done inline as it's discovered by the fs.glob loop, instead of in a separate Promise.all pass. Since require() is synchronous, the Promise wrapper that pass used is no longer needed, and the intermediate instanceFile bookkeeping property can be dropped along with it. --- lib/config-loader.js | 31 ++++++++----------------------- 1 file changed, 8 insertions(+), 23 deletions(-) diff --git a/lib/config-loader.js b/lib/config-loader.js index 9a4946c..2cc4bc3 100644 --- a/lib/config-loader.js +++ b/lib/config-loader.js @@ -56,30 +56,15 @@ module.exports = async function configLoader(api, options) { } } - if (entry.name === 'index.js') resources[resourceName].instanceFile = absoluteFilePath; + 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); + } } - // 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(); - }); - }) - ); - - // done return resources; }; From d67e4c19bab091d979453a4618bf77e84adf2e6e Mon Sep 17 00:00:00 2001 From: Marco Baumgartl Date: Mon, 10 Aug 2026 16:50:21 +0200 Subject: [PATCH 13/30] refactor: simplify config directory existence check Replace the access().then(() => true).catch(() => false) boolean dance with a plain try/catch around fs.access(). Also throw ImplementationError instead of a plain Error, consistent with the rest of the file, and rephrase the message to "Cannot access ..." since access() can fail for reasons other than the directory not existing (e.g. missing permissions); the original error is attached as cause. --- lib/config-loader.js | 11 ++++------- test/config-loader.spec.js | 2 +- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/lib/config-loader.js b/lib/config-loader.js index 2cc4bc3..4b27b1f 100644 --- a/lib/config-loader.js +++ b/lib/config-loader.js @@ -24,13 +24,10 @@ module.exports = async function configLoader(api, options) { 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 { + await fs.access(configDirectory); + } catch (err) { + throw new ImplementationError(`Cannot access config directory "${configDirectory}"`, { cause: err }); } const resources = {}; diff --git a/test/config-loader.spec.js b/test/config-loader.spec.js index 182a874..7594ccb 100644 --- a/test/config-loader.spec.js +++ b/test/config-loader.spec.js @@ -27,7 +27,7 @@ describe('config-loader', () => { await assert.rejects( configLoader(api, { directory }), - new Error(`Config directory "${directory}" does not exist`) + new ImplementationError(`Cannot access config directory "${directory}"`) ); }); From 16683fa7e7c936d953c4bff3cef06ea21bb63593 Mon Sep 17 00:00:00 2001 From: Marco Baumgartl Date: Wed, 12 Aug 2026 08:58:16 +0200 Subject: [PATCH 14/30] test: back url-parser request mocks with real Readable streams Replaces the hand-rolled POJO mock with stream.Readable-based fixtures so tests exercise the actual Node stream interface instead of a reimplementation of it, and adds coverage for bodies delivered in multiple chunks and across real async ticks. --- test/url-parser.spec.js | 118 ++++++++++++++++++++++++---------------- 1 file changed, 72 insertions(+), 46 deletions(-) diff --git a/test/url-parser.spec.js b/test/url-parser.spec.js index e993fef..ac09a5c 100644 --- a/test/url-parser.spec.js +++ b/test/url-parser.spec.js @@ -1,34 +1,30 @@ 'use strict'; +const { Readable } = require('node:stream'); const { describe, it, beforeEach } = 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,10 +136,13 @@ describe('HTTP request parsing', () => { describe('POST payload', () => { it('should parse JSON payload', async () => { + const body = '{"a":true}'; + httpRequest = createRequest({ + method: 'POST', + headers: { 'content-type': 'application/json', 'content-length': body.length }, + body: [...body] + }); httpRequest.url = 'http://api.example.com/user/'; - httpRequest.payload = '{"a": true}'; - httpRequest.method = 'POST'; - httpRequest.headers['content-length'] = httpRequest.payload.length; const request = await parseRequest(httpRequest); @@ -158,11 +157,13 @@ describe('HTTP request parsing', () => { }); it('should parse form-urlencoded payload', async () => { + const body = 'a=true&b=false'; + httpRequest = createRequest({ + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded', 'content-length': body.length }, + body: [...body] + }); 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 request = await parseRequest(httpRequest); @@ -180,6 +181,28 @@ 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; + } + } + + httpRequest = createRequest({ + method: 'POST', + headers: { 'content-type': 'application/json', 'content-length': body.length }, + body: delayedChunks() + }); + httpRequest.url = 'http://api.example.com/user/'; + + const request = await parseRequest(httpRequest, { postTimeout: 1000 }); + + assert.equal(request.data.a, true); + }); + [ { description: 'should reject POST with malformed Content-Type header', @@ -198,11 +221,12 @@ describe('HTTP request parsing', () => { } ].forEach(({ description, mutate, message }) => { it(description, async () => { + const body = '{"a":true}'; + const headers = { 'content-type': 'application/json', 'content-length': body.length }; + mutate(headers); + + httpRequest = createRequest({ method: 'POST', headers, body: [...body] }); httpRequest.url = 'http://api.example.com/user/'; - httpRequest.payload = '{"a": true}'; - httpRequest.method = 'POST'; - httpRequest.headers['content-length'] = httpRequest.payload.length; - mutate(httpRequest.headers); await assert.rejects(parseRequest(httpRequest), { name: 'RequestError', @@ -212,18 +236,15 @@ describe('HTTP request parsing', () => { }); 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' @@ -239,11 +260,13 @@ describe('HTTP request parsing', () => { }); it('should remove protected properties (urlencoded)', async () => { + const body = '_auth=FOO'; + httpRequest = createRequest({ + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded', 'content-length': body.length }, + body: [...body] + }); 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 request = await parseRequest(httpRequest); @@ -252,10 +275,13 @@ describe('HTTP request parsing', () => { }); it('should remove protected properties (JSON)', async () => { + const body = '{"_auth":"FOO"}'; + httpRequest = createRequest({ + method: 'POST', + headers: { 'content-type': 'application/json', 'content-length': body.length }, + body: [...body] + }); httpRequest.url = 'http://api.example.com/user/'; - httpRequest.payload = '{"_auth": "FOO"}'; - httpRequest.method = 'POST'; - httpRequest.headers['content-length'] = httpRequest.payload.length; const request = await parseRequest(httpRequest); From ef0d1c91b5bea051f29fec3739f764bfb71fceb9 Mon Sep 17 00:00:00 2001 From: Marco Baumgartl Date: Wed, 12 Aug 2026 09:01:25 +0200 Subject: [PATCH 15/30] test: use local request variable in POST payload tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST tests never reused the beforeEach-provided GET request — they overrode method, headers and body anyway, so reassigning the shared httpRequest variable only obscured that each test builds its own independent request. --- test/url-parser.spec.js | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/test/url-parser.spec.js b/test/url-parser.spec.js index ac09a5c..4cd94b3 100644 --- a/test/url-parser.spec.js +++ b/test/url-parser.spec.js @@ -137,14 +137,14 @@ describe('HTTP request parsing', () => { describe('POST payload', () => { it('should parse JSON payload', async () => { const body = '{"a":true}'; - httpRequest = createRequest({ + const req = createRequest({ method: 'POST', headers: { 'content-type': 'application/json', 'content-length': body.length }, body: [...body] }); - httpRequest.url = 'http://api.example.com/user/'; + 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,14 +158,14 @@ describe('HTTP request parsing', () => { it('should parse form-urlencoded payload', async () => { const body = 'a=true&b=false'; - httpRequest = createRequest({ + const req = createRequest({ method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded', 'content-length': body.length }, body: [...body] }); - httpRequest.url = 'http://api.example.com/user/'; + 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')); @@ -191,14 +191,14 @@ describe('HTTP request parsing', () => { } } - httpRequest = createRequest({ + const req = createRequest({ method: 'POST', headers: { 'content-type': 'application/json', 'content-length': body.length }, body: delayedChunks() }); - httpRequest.url = 'http://api.example.com/user/'; + req.url = 'http://api.example.com/user/'; - const request = await parseRequest(httpRequest, { postTimeout: 1000 }); + const request = await parseRequest(req, { postTimeout: 1000 }); assert.equal(request.data.a, true); }); @@ -225,10 +225,10 @@ describe('HTTP request parsing', () => { const headers = { 'content-type': 'application/json', 'content-length': body.length }; mutate(headers); - httpRequest = createRequest({ method: 'POST', headers, body: [...body] }); - httpRequest.url = 'http://api.example.com/user/'; + const req = createRequest({ method: 'POST', headers, body: [...body] }); + req.url = 'http://api.example.com/user/'; - await assert.rejects(parseRequest(httpRequest), { + await assert.rejects(parseRequest(req), { name: 'RequestError', message }); @@ -261,14 +261,14 @@ describe('HTTP request parsing', () => { it('should remove protected properties (urlencoded)', async () => { const body = '_auth=FOO'; - httpRequest = createRequest({ + const req = createRequest({ method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded', 'content-length': body.length }, body: [...body] }); - httpRequest.url = 'http://api.example.com/user/'; + 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); @@ -276,14 +276,14 @@ describe('HTTP request parsing', () => { it('should remove protected properties (JSON)', async () => { const body = '{"_auth":"FOO"}'; - httpRequest = createRequest({ + const req = createRequest({ method: 'POST', headers: { 'content-type': 'application/json', 'content-length': body.length }, body: [...body] }); - httpRequest.url = 'http://api.example.com/user/'; + 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); From 8899446c9bf9a0f644715423b0d5cbdd241c6e32 Mon Sep 17 00:00:00 2001 From: Marco Baumgartl Date: Wed, 12 Aug 2026 09:05:43 +0200 Subject: [PATCH 16/30] test: cover the request stream error path in url-parser The httpRequest.on('error', ...) handler had no test, despite guarding against a real crash risk: an unhandled 'error' event on an EventEmitter throws. Destroys the request stream while it's still being read to verify the rejection message. --- test/url-parser.spec.js | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/test/url-parser.spec.js b/test/url-parser.spec.js index 4cd94b3..4de29b8 100644 --- a/test/url-parser.spec.js +++ b/test/url-parser.spec.js @@ -251,6 +251,26 @@ describe('HTTP request parsing', () => { }); }); + it('should reject if the request stream emits an error', async () => { + 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 pending = parseRequest(req); + req.destroy(new Error('socket hang up')); + + await assert.rejects(pending, { + name: 'RequestError', + message: 'Error reading HTTP-Request: socket hang up' + }); + }); + it('should remove protected properties (GET)', async () => { httpRequest.url = 'http://api.example.com/user/1337.jpg?_auth=FOO'; const request = await parseRequest(httpRequest); From cd6d5cf868c5668c6159fa307c75d69c49625cb6 Mon Sep 17 00:00:00 2001 From: Marco Baumgartl Date: Wed, 12 Aug 2026 10:42:49 +0200 Subject: [PATCH 17/30] test: cover url-parser event handling against a real http.Server --- test/url-parser.spec.js | 126 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 125 insertions(+), 1 deletion(-) diff --git a/test/url-parser.spec.js b/test/url-parser.spec.js index 4de29b8..545c3c9 100644 --- a/test/url-parser.spec.js +++ b/test/url-parser.spec.js @@ -1,7 +1,8 @@ 'use strict'; const { Readable } = require('node:stream'); -const { describe, it, beforeEach } = require('node:test'); +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'); @@ -309,4 +310,127 @@ describe('HTTP request parsing', () => { 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 () => { + const { promise: result, resolve, reject } = Promise.withResolvers(); + + const port = await startServer((req) => { + parseRequest(req).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' }, + // 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' + }); + }); + + 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(); + }); + }); }); From d7fffa3ddecee9c8a4169e4698752d4b1d9ee41b Mon Sep 17 00:00:00 2001 From: Marco Baumgartl Date: Wed, 12 Aug 2026 11:14:51 +0200 Subject: [PATCH 18/30] refactor: use named capture groups when parsing the request path --- lib/url-parser.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/url-parser.js b/lib/url-parser.js index 0de8e77..e6a6822 100644 --- a/lib/url-parser.js +++ b/lib/url-parser.js @@ -19,7 +19,7 @@ const Request = require('./request'); 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,16 +28,16 @@ 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]; - parsedUrl.searchParams.forEach((value, param) => { if (!Object.hasOwn(opts, param)) { if (parsedUrl.searchParams.getAll(param).length > 1) { From dca0c2db207db081f59cfd0ffd32ba3c6965b162 Mon Sep 17 00:00:00 2001 From: Marco Baumgartl Date: Wed, 12 Aug 2026 11:18:11 +0200 Subject: [PATCH 19/30] docs: fix httpToFloraRequest jsdoc parameter types --- lib/url-parser.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/url-parser.js b/lib/url-parser.js index e6a6822..255b15f 100644 --- a/lib/url-parser.js +++ b/lib/url-parser.js @@ -11,8 +11,9 @@ 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 */ From d388ab541dde6d3edcdc450835f3a7f316682712 Mon Sep 17 00:00:00 2001 From: Marco Baumgartl Date: Wed, 12 Aug 2026 13:39:32 +0200 Subject: [PATCH 20/30] refactor: clear postTimeout via close event instead of at each call site Replace the four scattered clearTimeout calls with a single `httpRequest.once('close', ...)` listener, since 'close' fires exactly once regardless of how the request concludes (success, abort, or error). Add tests asserting clearTimeout is actually invoked on the success, error, and aborted paths. --- lib/url-parser.js | 27 +++++++-------------------- test/url-parser.spec.js | 26 ++++++++++++++++++++++---- 2 files changed, 29 insertions(+), 24 deletions(-) diff --git a/lib/url-parser.js b/lib/url-parser.js index 255b15f..39153e2 100644 --- a/lib/url-parser.js +++ b/lib/url-parser.js @@ -75,11 +75,14 @@ function httpToFloraRequest(httpRequest, { postTimeout } = {}) { contentType.essence === 'application/json' || contentType.essence === 'application/x-www-form-urlencoded' ) { - let timeout; 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'; @@ -91,10 +94,6 @@ function httpToFloraRequest(httpRequest, { postTimeout } = {}) { }); httpRequest.on('aborted', () => { - if (timeout) { - clearTimeout(timeout); - timeout = null; - } reject(new RequestError('HTTP request has been aborted')); }); @@ -105,10 +104,6 @@ function httpToFloraRequest(httpRequest, { postTimeout } = {}) { if (!Object.hasOwn(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; } @@ -122,10 +117,6 @@ function httpToFloraRequest(httpRequest, { postTimeout } = {}) { 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; } @@ -133,10 +124,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/test/url-parser.spec.js b/test/url-parser.spec.js index 545c3c9..6df583d 100644 --- a/test/url-parser.spec.js +++ b/test/url-parser.spec.js @@ -252,7 +252,21 @@ describe('HTTP request parsing', () => { }); }); - it('should reject if the request stream emits an error', async () => { + 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: { @@ -263,13 +277,15 @@ describe('HTTP request parsing', () => { }); req.url = '/user/'; - const pending = parseRequest(req); + 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 () => { @@ -371,13 +387,14 @@ describe('HTTP request parsing', () => { assert.deepEqual(body, { ok: true, data: { a: true } }); }); - it('should reject with "HTTP request has been aborted" if the client disconnects mid-body', async () => { + 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).then(resolve, reject); + 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', @@ -402,6 +419,7 @@ describe('HTTP request parsing', () => { 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 () => { From 2c8cf74333289e2c892a72d315f3a9c54acebc3e Mon Sep 17 00:00:00 2001 From: Marco Baumgartl Date: Wed, 12 Aug 2026 13:42:59 +0200 Subject: [PATCH 21/30] refactor: use once for event listeners that fire at most once 'error', 'aborted', and 'end' each occur at most once per request, so make that explicit instead of using on(). --- lib/url-parser.js | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/lib/url-parser.js b/lib/url-parser.js index 39153e2..802dbf2 100644 --- a/lib/url-parser.js +++ b/lib/url-parser.js @@ -53,7 +53,7 @@ function httpToFloraRequest(httpRequest, { postTimeout } = {}) { /* * 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 = ''; @@ -93,11 +93,9 @@ function httpToFloraRequest(httpRequest, { postTimeout } = {}) { payload += chunk; }); - httpRequest.on('aborted', () => { - reject(new RequestError('HTTP request has been aborted')); - }); + httpRequest.once('aborted', () => reject(new RequestError('HTTP request has been aborted'))); - httpRequest.on('end', () => { + httpRequest.once('end', () => { if (contentType.essence === 'application/x-www-form-urlencoded') { payload = querystring.parse(payload); Object.keys(payload).forEach((key) => { From 767f2ff059cde7a49cba1653d998751716991a23 Mon Sep 17 00:00:00 2001 From: Marco Baumgartl Date: Wed, 12 Aug 2026 13:54:17 +0200 Subject: [PATCH 22/30] refactor: use for-of loop for GET query parameters forEach can't stop iterating once a duplicate parameter is found, so it kept checking the remaining parameters unnecessarily. A for-of loop can return immediately once rejecting. --- lib/url-parser.js | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/lib/url-parser.js b/lib/url-parser.js index 802dbf2..61c60db 100644 --- a/lib/url-parser.js +++ b/lib/url-parser.js @@ -39,16 +39,16 @@ function httpToFloraRequest(httpRequest, { postTimeout } = {}) { _httpRequest: httpRequest }; - parsedUrl.searchParams.forEach((value, param) => { - if (!Object.hasOwn(opts, param)) { - if (parsedUrl.searchParams.getAll(param).length > 1) { - reject(new RequestError(`Duplicate parameter "${param}" in URL`)); - return; - } + for (const [param, value] of parsedUrl.searchParams) { + if (Object.hasOwn(opts, param)) continue; - 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. From c8a1850009ba80b7dcbf24f84d8dc2d933e1e7ae Mon Sep 17 00:00:00 2001 From: Marco Baumgartl Date: Wed, 12 Aug 2026 13:56:46 +0200 Subject: [PATCH 23/30] refactor: use for-of loop for urlencoded payload parameters Same rationale as the GET query parameter loop: forEach kept checking remaining keys after a duplicate was already found and rejected. --- lib/url-parser.js | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/lib/url-parser.js b/lib/url-parser.js index 61c60db..aaa39c9 100644 --- a/lib/url-parser.js +++ b/lib/url-parser.js @@ -98,17 +98,17 @@ function httpToFloraRequest(httpRequest, { postTimeout } = {}) { httpRequest.once('end', () => { if (contentType.essence === 'application/x-www-form-urlencoded') { payload = querystring.parse(payload); - Object.keys(payload).forEach((key) => { - if (!Object.hasOwn(opts, key)) { - if (Array.isArray(payload[key])) { - if (httpRequest.flora) httpRequest.flora.state = 'processing'; - 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 (contentType.essence === 'application/json') { try { From 481ae1cde3d393f48cd1f348e2eae350b1e7fab4 Mon Sep 17 00:00:00 2001 From: Marco Baumgartl Date: Wed, 12 Aug 2026 14:07:37 +0200 Subject: [PATCH 24/30] refactor: find first useless text node instead of filter+forEach filter().filter().forEach() always scanned all child nodes and threw from inside forEach; find() stops at the first match and the throw moves into a plain if-block. --- lib/xml-reader.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/xml-reader.js b/lib/xml-reader.js index 3d92e10..b80bcf4 100644 --- a/lib/xml-reader.js +++ b/lib/xml-reader.js @@ -124,12 +124,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'); From 39ea7d92bdeea3c73da621f58fcd1325f314a895 Mon Sep 17 00:00:00 2001 From: Marco Baumgartl Date: Wed, 12 Aug 2026 14:12:47 +0200 Subject: [PATCH 25/30] refactor: use object spread in copyXmlAttributes --- lib/xml-reader.js | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/lib/xml-reader.js b/lib/xml-reader.js index b80bcf4..8e5afd0 100644 --- a/lib/xml-reader.js +++ b/lib/xml-reader.js @@ -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) { From 4e6a4d13dde901e4d7a203f810bd439ac00d5bdf Mon Sep 17 00:00:00 2001 From: Marco Baumgartl Date: Wed, 12 Aug 2026 15:59:53 +0200 Subject: [PATCH 26/30] refactor: replace strRepeat helper with String.prototype.repeat --- lib/ascii-art-profile.js | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/lib/ascii-art-profile.js b/lib/ascii-art-profile.js index 49de8de..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; @@ -61,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 ); }); From 6072c9a17ffe0040ff8ff452e4c318fc792e9399 Mon Sep 17 00:00:00 2001 From: Marco Baumgartl Date: Wed, 12 Aug 2026 16:01:27 +0200 Subject: [PATCH 27/30] refactor: use spread instead of Array.prototype.push.apply --- lib/datasource-executor.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/datasource-executor.js b/lib/datasource-executor.js index 8836882..eebbeab 100644 --- a/lib/datasource-executor.js +++ b/lib/datasource-executor.js @@ -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); } From f7a50e20e181c14d0d1a132545b7fd2d182d3775 Mon Sep 17 00:00:00 2001 From: Marco Baumgartl Date: Wed, 12 Aug 2026 16:07:18 +0200 Subject: [PATCH 28/30] refactor: use object spread instead of Object.assign({}, ...) --- lib/datasource-executor.js | 2 +- lib/result-builder.js | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/datasource-executor.js b/lib/datasource-executor.js index eebbeab..d028c95 100644 --- a/lib/datasource-executor.js +++ b/lib/datasource-executor.js @@ -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; 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) { From 8732c136c99d1737afe71f6131c9c5a0f88fccff Mon Sep 17 00:00:00 2001 From: Marco Baumgartl Date: Wed, 12 Aug 2026 16:08:27 +0200 Subject: [PATCH 29/30] refactor: dedupe dataSourceAttributes using Set instead of indexOf filter --- lib/config-parser.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/config-parser.js b/lib/config-parser.js index c0e40ee..c6f0162 100644 --- a/lib/config-parser.js +++ b/lib/config-parser.js @@ -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); From 9c8f441986e26c8f2f673db2f0c88691b91c7cd2 Mon Sep 17 00:00:00 2001 From: Marco Baumgartl Date: Wed, 12 Aug 2026 16:10:25 +0200 Subject: [PATCH 30/30] refactor: replace indexOf(...) === -1 checks with Array.includes --- lib/config-parser.js | 2 +- lib/request-resolver.js | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/config-parser.js b/lib/config-parser.js index c6f0162..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 ); diff --git a/lib/request-resolver.js b/lib/request-resolver.js index 21af680..aa25e69 100644 --- a/lib/request-resolver.js +++ b/lib/request-resolver.js @@ -400,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('.') + '" ', @@ -437,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('.')}")` : '') + @@ -608,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('.') + '" ', @@ -1027,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;