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 9db2b8006b50152310d6e6a845694923736c4c02 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. Co-Authored-By: Claude --- 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 8f97ba7ca7e0ab4f30c043e26e77766e06b411ae 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 1431d57c73b3a5f253cb99c364cb495fa4a729ed 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 032f4c1f1d95386b9ab27f2706bb149e59062bef 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 b104eba0d4d146a16f1e46c5aa81b24957f46301 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 ca9ae9f96e5ee654ab9f8b560f8739bbe9631fb1 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 7f378ed80e719e7ce5fd2695d546dba5f3cccd78 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 e227c14f7c97295f2ff83dd1bdea9f6cd02565ab 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 dc55538efc34e2fc2733d3a043481e2e6a57b1a4 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 e7d8d9b4f25d578c08736592f6f103d89be0a96b 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 3fd0a4fa2f39297168b837d345c5f1c6d38c9df8 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 e004c06afb596005e97c2ecd099babe5460876aa 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 394470df944e1e69c1e8cb751d80c1ae974c98d6 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 8a73296772cc218b11f9cbf5fb6d5d7aabdfb889 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 c18b609eb0acaa767951705025666fae57c6dda4 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 1a27e5782e18d6e330058f33bd705c52b4c714c9 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 f23d88782be4abec6a670b9688e3ad86fc418876 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 75a33eaa1ed0ea092c144593faa757f369e50802 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 df8ea0cb1510b1b3f57092f8012133660fe4725a 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 763e854b55459a022247dbc0ed00455e644b5c30 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 18d85e7a9601fc398e5e23357acb32886ac34ae1 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;