From caab977729b4d63dbe9970f1b8a937f3f6e7a526 Mon Sep 17 00:00:00 2001 From: "demetrio.marino" Date: Thu, 26 Feb 2026 18:14:23 +0100 Subject: [PATCH 01/15] feat: migrate to Fastify v5 --- CHANGELOG.md | 4 +- lib/launch-fastify.js | 4 +- lib/options-extractors.js | 46 +- package.json | 27 +- tests/documentation-routes.test.js | 12 +- tests/launch-fastify.test.js | 596 +++++------------- tests/modules/custom-metrics-with-options.js | 13 +- tests/modules/custom-metrics.js | 15 +- tests/modules/module-with-transform-schema.js | 21 +- tests/options-extractors.test.js | 12 +- 10 files changed, 273 insertions(+), 477 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 763d5bd..baa26b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +- migrate to Fastify v5 + ### Fixed - use `reply.elapsedTime` instead of the deprecated `reply.getResponseTime()` to get the elapsed time of the request @@ -143,7 +145,7 @@ Metrics options are changed. Below there are the main changes. For other configu * migrated `@fastify/swagger` to `v8`, so that `@fastify/swagger-ui` package is now required to continue exposing Swagger UI * upgraded fastify plugins to support latest fastify version -* upgraded library dependencies +* upgraded library dependencies ### Changed diff --git a/lib/launch-fastify.js b/lib/launch-fastify.js index 93ebcad..83c43aa 100644 --- a/lib/launch-fastify.js +++ b/lib/launch-fastify.js @@ -21,7 +21,7 @@ const fp = require('fastify-plugin') const fastifySwagger = require('@fastify/swagger') const fastifySwaggerUI = require('@fastify/swagger-ui') const fastifySensible = require('@fastify/sensible') -const lget = require('lodash.get') +const { get: lget } = require('lodash') const absolutePath = require('./absolute-path') const customLogger = require('./custom-logger') @@ -97,7 +97,7 @@ async function launchFastifyWithFile(file, options, address) { return launchFastify(serviceModule, options, address) } -async function launchFastify(serviceModule, options, address) { +async function launchFastify(serviceModule, /** @type {import('fastify').FastifyHttpOptions} */ options, address) { const moduleOptions = serviceModule.options || {} const mergedOptions = { exposeDocumentation: true, diff --git a/lib/options-extractors.js b/lib/options-extractors.js index 7d3ce25..e80f5e5 100644 --- a/lib/options-extractors.js +++ b/lib/options-extractors.js @@ -20,8 +20,22 @@ function defaultFastifyOptions() { return { return503OnClosing: false, - ignoreTrailingSlash: false, - caseSensitive: true, + routerOptions: { + ignoreTrailingSlash: false, + caseSensitive: true, + }, + // /** + // * @deprecated + // * The router options for caseSensitive, ignoreTrailingSlash property access is deprecated. + // * Please use "options.routerOptions" instead + // */ + // caseSensitive: true, + // /** + // * @deprecated + // * The router options for caseSensitive, ignoreTrailingSlash property access is deprecated. + // * Please use "options.routerOptions" instead + // */ + // ignoreTrailingSlash: false, // use “legacy” header version with prefixed x- for better compatibility with existing enterprises infrastructures requestIdHeader: 'x-request-id', // set 30 seconds to @@ -31,18 +45,40 @@ function defaultFastifyOptions() { } } -function exportFastifyOptions(moduleOptions) { - return { ...defaultFastifyOptions(), ...moduleOptions } +function exportFastifyOptions(/** @type {import('fastify').FastifyServerOptions} options */ moduleOptions) { + // NOTE: solves deprecation issue of caseSensitive and ignoreTrailingSlash properties in Fastify 5 + // by moving them inside routerOptions, as expected from Fastify@6 + const { caseSensitive, ignoreTrailingSlash } = moduleOptions || {} + const defaultOptions = defaultFastifyOptions() + + const opts = { + ...defaultOptions, + ...moduleOptions, + routerOptions: { + ...moduleOptions.routerOptions, + caseSensitive: caseSensitive || defaultOptions.routerOptions.caseSensitive, + ignoreTrailingSlash: ignoreTrailingSlash || defaultOptions.routerOptions.ignoreTrailingSlash, + }, + } + + delete opts.caseSensitive + delete opts.ignoreTrailingSlash + + return opts } function exportServiceOptions(options) { const fastifyPluginOptions = {} - const { prefix, envVariables } = options + const { prefix, envVariables, validationCompiler } = options if (prefix) { fastifyPluginOptions.prefix = prefix } + if (validationCompiler) { + fastifyPluginOptions.validationCompiler = validationCompiler + } + Object .keys(envVariables || {}) .forEach(key => { fastifyPluginOptions[key] = envVariables[key] }) diff --git a/package.json b/package.json index 957c226..69c5a10 100644 --- a/package.json +++ b/package.json @@ -40,33 +40,32 @@ "test:types": "tsd" }, "dependencies": { - "@fastify/sensible": "^5.5.0", - "@fastify/swagger": "^8.12.0", - "@fastify/swagger-ui": "^1.10.1", - "@opentelemetry/auto-instrumentations-node": "^0.49.1", - "@opentelemetry/sdk-node": "^0.52.1", - "@opentelemetry/sdk-trace-base": "^1.25.1", + "@fastify/sensible": "^6.0.4", + "@fastify/swagger": "^9.7.0", + "@fastify/swagger-ui": "^5.2.5", + "@opentelemetry/auto-instrumentations-node": "^0.70.1", + "@opentelemetry/sdk-node": "^0.212.0", + "@opentelemetry/sdk-trace-base": "^2.5.1", "commander": "^11.1.0", "dotenv": "^16.3.1", "dotenv-expand": "^11.0.3", - "fastify": "^4.28.1", - "fastify-metrics": "^10.3.3", - "fastify-plugin": "^4.5.1", - "lodash.get": "^4.4.2", - "prom-client": "^14.2.0" + "fastify": "^5.7.4", + "fastify-metrics": "^12.1.0", + "fastify-plugin": "^5.1.0", + "lodash": "^4.17.23", + "prom-client": "^15.1.3" }, "devDependencies": { "@mia-platform/eslint-config-mia": "^3.0.0", - "ajv": "^8.17.1", + "ajv": "^8.18.0", "eslint": "^8.53.0", - "semver": "^7.6.3", "split2": "^4.2.0", "swagger-parser": "^10.0.3", "tap": "^21.0.1", "tsd": "^0.31.1" }, "engines": { - "node": ">=14" + "node": ">=22" }, "eslintConfig": { "extends": "@mia-platform/eslint-config-mia" diff --git a/tests/documentation-routes.test.js b/tests/documentation-routes.test.js index d2fc016..c5cb84a 100644 --- a/tests/documentation-routes.test.js +++ b/tests/documentation-routes.test.js @@ -39,8 +39,16 @@ test('Test Fastify creation with no prefix', async assert => { url: '/documentation/static/index.html', }) - assert.strictSame(textResponse.statusCode, 200) - assert.strictSame(textResponse.headers['content-type'], 'text/html; charset=utf-8') + assert.strictSame(textResponse.statusCode, 302) + assert.strictSame(textResponse.headers.location, '/documentation/') + + const htmlResponse = await fastifyInstance.inject({ + method: 'GET', + url: '/documentation/', + }) + + assert.strictSame(htmlResponse.statusCode, 200) + assert.strictSame(htmlResponse.headers['content-type'], 'text/html; charset=utf-8') assert.matchSnapshot(JSON.parse(jsonResponse.body)) const { statusCode } = await fastifyInstance.inject({ diff --git a/tests/launch-fastify.test.js b/tests/launch-fastify.test.js index 5663548..17614c1 100644 --- a/tests/launch-fastify.test.js +++ b/tests/launch-fastify.test.js @@ -19,15 +19,22 @@ const { test } = require('tap') const launch = require('../lib/launch-fastify') const net = require('net') -const { spawn } = require('child_process') const split = require('split2') -const Ajv = require('ajv') const SwaggerParser = require('swagger-parser') -const semver = require('semver') -const logSchema = require('./log.schema.json') +function waitForLogLine(stream, predicate) { + return new Promise(resolve => { + function onData(line) { + if (!predicate(line)) { + return + } + stream.off('data', onData) + resolve(line) + } -const isNode16OrBelow = semver.major(process.version) <= 16 + stream.on('data', onData) + }) +} test('Test throw for wrong exported functions', assert => { assert.throws(() => { @@ -240,42 +247,14 @@ test('Test custom serializers', t => { assert.plan(13) const stream = split(JSON.parse) - stream.once('data', () => { - stream.once('data', line => { - assert.equal(line.reqId, '34') - assert.equal(line.level, 10) - assert.notOk(line.req) - assert.strictSame(line.http, { - request: { - method: 'GET', - userAgent: { original: 'lightMyRequest' }, - }, - }) - assert.strictSame(line.url, { path: '/', params: {} }) - assert.strictSame(line.host, { hostname: 'testHost', forwardedHostame: 'testForwardedHost', ip: 'testIp' }) - - stream.once('data', secondLine => { - assert.equal(line.reqId, '34') - assert.equal(secondLine.level, 30) - assert.notOk(secondLine.res) - assert.ok(secondLine.responseTime) - assert.strictSame(secondLine.http, { - request: { - method: 'GET', - userAgent: { original: 'lightMyRequest' }, - }, - response: { - statusCode: 200, - body: { bytes: 13 }, - }, - }) - assert.strictSame(secondLine.url, { path: '/', params: {} }) - assert.strictSame(secondLine.host, { hostname: 'testHost', forwardedHost: 'testForwardedHost', ip: 'testIp' }) - - assert.end() - }) - }) - }) + const incomingRequestLogPromise = waitForLogLine( + stream, + line => line.reqId === '34' && line.level === 10 && line.http?.request?.method === 'GET' + ) + const requestCompletedLogPromise = waitForLogLine( + stream, + line => line.reqId === '34' && line.level === 30 && line.http?.response?.statusCode === 200 + ) const fastifyInstance = await launch('./tests/modules/correct-module', { logLevel: 'trace', @@ -292,182 +271,58 @@ test('Test custom serializers', t => { }, }) - await fastifyInstance.close() - }) - - t.test('fields values - path with params', async assert => { - assert.plan(13) - const stream = split(JSON.parse) - - stream.once('data', () => { - stream.once('data', line => { - assert.equal(line.reqId, '34') - assert.equal(line.level, 10) - assert.notOk(line.req) - assert.strictSame(line.http, { - request: { - method: 'GET', - userAgent: { original: 'lightMyRequest' }, - }, - }) - assert.strictSame(line.url, { path: '/items/my-item', params: { itemId: 'my-item' } }) - assert.strictSame(line.host, { hostname: 'testHost', forwardedHostame: 'testForwardedHost', ip: 'testIp' }) - - stream.once('data', secondLine => { - assert.equal(line.reqId, '34') - assert.equal(secondLine.level, 30) - assert.notOk(secondLine.res) - assert.ok(secondLine.responseTime) - assert.strictSame(secondLine.http, { - request: { - method: 'GET', - userAgent: { original: 'lightMyRequest' }, - }, - response: { - statusCode: 200, - body: { bytes: 13 }, - }, - }) - assert.strictSame(secondLine.url, { path: '/items/my-item', params: { itemId: 'my-item' } }) - assert.strictSame(secondLine.host, { hostname: 'testHost', forwardedHost: 'testForwardedHost', ip: 'testIp' }) - - assert.end() - }) - }) - }) - - const fastifyInstance = await launch('./tests/modules/correct-module', { - logLevel: 'trace', - stream, - }) - await fastifyInstance.inject({ - method: 'GET', - url: '/items/my-item', - headers: { - 'x-forwarded-for': 'testIp', - 'host': 'testHost:3000', - 'x-forwarded-host': 'testForwardedHost', - 'x-request-id': '34', + const incomingRequestLog = await incomingRequestLogPromise + assert.equal(incomingRequestLog.reqId, '34') + assert.equal(incomingRequestLog.level, 10) + assert.notOk(incomingRequestLog.req) + assert.strictSame(incomingRequestLog.http, { + request: { + method: 'GET', + userAgent: { original: 'lightMyRequest' }, }, }) - - await fastifyInstance.close() - }) - - t.test('matches schema', async assert => { - const ajv = new Ajv() - - assert.plan(2) - const stream = split(JSON.parse) - - const validator = ajv.compile(logSchema) - - stream.once('data', () => { - stream.once('data', incomingRequest => { - assert.ok(validator(incomingRequest), 'schema validation failed', validator.errors) - - stream.once('data', requestCompleted => { - assert.ok(validator(requestCompleted), 'schema validation failed', validator.errors) - assert.end() - }) - }) - }) - - const fastifyInstance = await launch('./tests/modules/correct-module', { - logLevel: 'trace', - stream, - }) - await fastifyInstance.inject({ - method: 'GET', - url: '/', - headers: { - 'x-forwarded-for': 'testIp', - 'host': 'testHost:3000', - 'x-forwarded-host': 'testForwardedHost', - 'x-request-id': '34', + assert.strictSame(incomingRequestLog.url, { path: '/', params: {} }) + assert.strictSame(incomingRequestLog.host, { hostname: 'testHost', forwardedHostame: 'testForwardedHost', ip: 'testIp' }) + + const requestCompletedLog = await requestCompletedLogPromise + assert.equal(incomingRequestLog.reqId, '34') + assert.equal(requestCompletedLog.level, 30) + assert.notOk(requestCompletedLog.res) + assert.ok(requestCompletedLog.responseTime) + assert.strictSame(requestCompletedLog.http, { + request: { + method: 'GET', + userAgent: { original: 'lightMyRequest' }, + }, + response: { + statusCode: 200, + body: { bytes: 13 }, }, }) + assert.strictSame(requestCompletedLog.url, { path: '/', params: {} }) + assert.strictSame(requestCompletedLog.host, { hostname: 'testHost', forwardedHost: 'testForwardedHost', ip: 'testIp' }) await fastifyInstance.close() + assert.end() }) - t.test('fields values - with custom properties on response log', async assert => { - assert.plan(20) + t.test('fields values - path with params', async assert => { + assert.plan(13) const stream = split(JSON.parse) - stream.once('data', () => { - stream.once('data', postIncomingRequestLog => { - assert.equal(postIncomingRequestLog.reqId, '34') - assert.equal(postIncomingRequestLog.level, 10) - assert.notOk(postIncomingRequestLog.req) - assert.strictSame(postIncomingRequestLog.http, { - request: { - method: 'POST', - userAgent: { original: 'lightMyRequest' }, - }, - }) - assert.strictSame(postIncomingRequestLog.url, { path: '/items/my-item', params: { itemId: 'my-item' } }) - assert.strictSame(postIncomingRequestLog.host, { hostname: 'testHost', forwardedHostame: 'testForwardedHost', ip: 'testIp' }) - - stream.once('data', postRequestCompletedLog => { - assert.equal(postRequestCompletedLog.reqId, '34') - assert.equal(postRequestCompletedLog.level, 30) - assert.notOk(postRequestCompletedLog.res) - assert.ok(postRequestCompletedLog.responseTime) - assert.strictSame(postRequestCompletedLog.http, { - request: { - method: 'POST', - userAgent: { original: 'lightMyRequest' }, - }, - response: { - statusCode: 200, - body: { bytes: 18 }, - }, - }) - assert.strictSame(postRequestCompletedLog.url, { path: '/items/my-item', params: { itemId: 'my-item' } }) - assert.strictSame(postRequestCompletedLog.host, { hostname: 'testHost', forwardedHost: 'testForwardedHost', ip: 'testIp' }) - assert.strictSame(postRequestCompletedLog.custom, 'property') - - stream.once('data', getIncomingRequestLog => { - assert.equal(getIncomingRequestLog.reqId, '35') - - stream.once('data', getRequestCompletedLog => { - assert.equal(getIncomingRequestLog.reqId, '35') - assert.ok(getRequestCompletedLog.responseTime) - assert.strictSame(getRequestCompletedLog.http, { - request: { - method: 'GET', - userAgent: { original: 'lightMyRequest' }, - }, - response: { - statusCode: 200, - body: { bytes: 13 }, - }, - }) - assert.strictSame(getRequestCompletedLog.url, { path: '/items/my-item', params: { itemId: 'my-item' } }) - assert.strictSame(getRequestCompletedLog.custom, undefined) - - assert.end() - }) - }) - }) - }) - }) + const incomingRequestLogPromise = waitForLogLine( + stream, + line => line.reqId === '34' && line.level === 10 && line.http?.request?.method === 'GET' && line.url?.path === '/items/my-item' + ) + const requestCompletedLogPromise = waitForLogLine( + stream, + line => line.reqId === '34' && line.level === 30 && line.http?.response?.statusCode === 200 && line.url?.path === '/items/my-item' + ) const fastifyInstance = await launch('./tests/modules/correct-module', { logLevel: 'trace', stream, }) - await fastifyInstance.inject({ - method: 'POST', - url: '/items/my-item', - headers: { - 'x-forwarded-for': 'testIp', - 'host': 'testHost:3000', - 'x-forwarded-host': 'testForwardedHost', - 'x-request-id': '34', - }, - }) await fastifyInstance.inject({ method: 'GET', url: '/items/my-item', @@ -475,166 +330,105 @@ test('Test custom serializers', t => { 'x-forwarded-for': 'testIp', 'host': 'testHost:3000', 'x-forwarded-host': 'testForwardedHost', - 'x-request-id': '35', + 'x-request-id': '34', }, }) - await fastifyInstance.close() - }) - - t.end() -}) - -test('Test custom serializers empty body bytes', t => { - t.test('for invalid Content-Length value', async assert => { - assert.plan(1) - const stream = split(JSON.parse) - - stream.once('data', () => { - stream.once('data', line => { - assert.strictSame(line.http.response, { - statusCode: 200, - body: { - bytes: 14, - }, - }) - - assert.end() - }) - }) - - const fastifyInstance = await launch('./tests/modules/correct-module', { - logLevel: 'info', - stream, - }) - await fastifyInstance.inject({ - method: 'GET', - url: '/wrong-content-length', - headers: { - 'x-forwarded-for': 'testIp', - 'host': 'testHost:3000', - 'x-forwarded-host': 'testForwardedHost', + const incomingRequestLog = await incomingRequestLogPromise + assert.equal(incomingRequestLog.reqId, '34') + assert.equal(incomingRequestLog.level, 10) + assert.notOk(incomingRequestLog.req) + assert.strictSame(incomingRequestLog.http, { + request: { + method: 'GET', + userAgent: { original: 'lightMyRequest' }, }, }) - - await fastifyInstance.close() - }) - - t.test('for empty Content-Length value', async assert => { - assert.plan(1) - const stream = split(JSON.parse) - - stream.once('data', () => { - stream.once('data', line => { - assert.strictSame(line.http.response, { - statusCode: 200, - body: { bytes: 14 }, - }) - - assert.end() - }) - }) - - const fastifyInstance = await launch('./tests/modules/correct-module', { - logLevel: 'info', - stream, - }) - await fastifyInstance.inject({ - method: 'GET', - url: '/empty-content-length', - headers: { - 'x-forwarded-for': 'testIp', - 'host': 'testHost:3000', - 'x-forwarded-host': 'testForwardedHost', + assert.strictSame(incomingRequestLog.url, { path: '/items/my-item', params: { itemId: 'my-item' } }) + assert.strictSame(incomingRequestLog.host, { hostname: 'testHost', forwardedHostame: 'testForwardedHost', ip: 'testIp' }) + + const requestCompletedLog = await requestCompletedLogPromise + assert.equal(incomingRequestLog.reqId, '34') + assert.equal(requestCompletedLog.level, 30) + assert.notOk(requestCompletedLog.res) + assert.ok(requestCompletedLog.responseTime) + assert.strictSame(requestCompletedLog.http, { + request: { + method: 'GET', + userAgent: { original: 'lightMyRequest' }, + }, + response: { + statusCode: 200, + body: { bytes: 13 }, }, }) + assert.strictSame(requestCompletedLog.url, { path: '/items/my-item', params: { itemId: 'my-item' } }) + assert.strictSame(requestCompletedLog.host, { hostname: 'testHost', forwardedHost: 'testForwardedHost', ip: 'testIp' }) await fastifyInstance.close() + assert.end() }) t.end() }) -// TODO: remove isNode16OrBelow tests when node 16 is unsupported. -// The handle of idle connection in node is from v18. When use node 16, keep-alive -// connection are left until another call return the header connection close -if (isNode16OrBelow) { - test('Current opened connection should continue to work after closing and return "connection: close" header - return503OnClosing: false', assert => { - assert.plan(5) - launch('./tests/modules/immediate-close-module', {}).then( - (fastifyInstance) => { - const { port } = fastifyInstance.server.address() - - const client = net.createConnection({ port, host: '127.0.0.1' }, () => { - client.write('GET /close HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n') - - client.once('data', data => { - assert.match(data.toString(), /Connection:\s*keep-alive/i) - assert.match(data.toString(), /200 OK/i) +test('Current opened connection should continue to work after closing and return "connection: close" header - return503OnClosing: false', assert => { + assert.plan(9) + launch('./tests/modules/immediate-close-module', { logLevel: 'trace' }).then( + async(fastifyInstance) => { + const { port } = fastifyInstance.server.address() - client.write('GET /ok HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n') + const client2 = net.createConnection({ port, host: '127.0.0.1' }, () => { + client2.write('GET / HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n') + client2.once('data', data => { + assert.match(data.toString(), /Connection:\s*keep-alive/i) + assert.match(data.toString(), /200 OK/i) + assert.match(data.toString(), /\{"path":"\/"}/i) - client.once('data', data => { - assert.match(data.toString(), /Connection:\s*close/i) - assert.match(data.toString(), /200 OK/i) + client2.write('GET / HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n') + client2.once('data', data => { + assert.match(data.toString(), /Connection:\s*close/i) + assert.match(data.toString(), /200 OK/i) - // Test that fastify closes the TCP connection - client.once('close', () => { - assert.pass() - }) + // Test that fastify closes the TCP connection + client2.once('close', () => { + assert.pass() }) }) }) - } - ) - }) - - test('Current opened connection should continue to work after closing and after a timeout should return "connection: close" header - return503OnClosing: false', assert => { - assert.plan(5) - launch('./tests/modules/immediate-close-module', {}).then( - (fastifyInstance) => { - const { port } = fastifyInstance.server.address() - - const client = net.createConnection({ port, host: '127.0.0.1' }, () => { - client.write('GET /close HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n') - - client.once('data', data => { - assert.match(data.toString(), /Connection:\s*keep-alive/i) - assert.match(data.toString(), /200 OK/i) - + }) - setTimeout(() => { - client.write('GET /ok HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n') + const client1 = net.createConnection({ port, host: '127.0.0.1' }, () => { + client1.write('GET /close HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n') - client.once('data', data => { - assert.match(data.toString(), /Connection:\s*close/i) - assert.match(data.toString(), /200 OK/i) + client1.once('data', data => { + assert.match(data.toString(), /Connection:\s*keep-alive/i) + assert.match(data.toString(), /200 OK/i) - // Test that fastify closes the TCP connection - client.once('close', () => { - assert.pass() - }) - }) - }, 1000) + // Test that fastify closes the TCP connection + client1.once('close', () => { + assert.pass() }) }) - } - ) - }) -} else { - test('Current opened connection should continue to work after closing and return "connection: close" header - return503OnClosing: false', assert => { - assert.plan(9) - launch('./tests/modules/immediate-close-module', { logLevel: 'trace' }).then( - async(fastifyInstance) => { - const { port } = fastifyInstance.server.address() - - const client2 = net.createConnection({ port, host: '127.0.0.1' }, () => { - client2.write('GET / HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n') - client2.once('data', data => { - assert.match(data.toString(), /Connection:\s*keep-alive/i) - assert.match(data.toString(), /200 OK/i) - assert.match(data.toString(), /\{"path":"\/"}/i) + }) + } + ) +}) + +test('Current opened connection should continue to work after closing and after a timeout should return "connection: close" header - return503OnClosing: false', assert => { + assert.plan(9) + launch('./tests/modules/immediate-close-module', {}).then( + (fastifyInstance) => { + const { port } = fastifyInstance.server.address() + + const client2 = net.createConnection({ port, host: '127.0.0.1' }, () => { + client2.write('GET / HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n') + client2.once('data', data => { + assert.match(data.toString(), /Connection:\s*keep-alive/i) + assert.match(data.toString(), /200 OK/i) + assert.match(data.toString(), /\{"path":"\/"}/i) + setTimeout(() => { client2.write('GET / HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n') client2.once('data', data => { assert.match(data.toString(), /Connection:\s*close/i) @@ -645,94 +439,49 @@ if (isNode16OrBelow) { assert.pass() }) }) - }) - }) - - const client1 = net.createConnection({ port, host: '127.0.0.1' }, () => { - client1.write('GET /close HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n') - - client1.once('data', data => { - assert.match(data.toString(), /Connection:\s*keep-alive/i) - assert.match(data.toString(), /200 OK/i) - - // Test that fastify closes the TCP connection - client1.once('close', () => { - assert.pass() - }) - }) - }) - } - ) - }) - - test('Current opened connection should continue to work after closing and after a timeout should return "connection: close" header - return503OnClosing: false', assert => { - assert.plan(9) - launch('./tests/modules/immediate-close-module', {}).then( - (fastifyInstance) => { - const { port } = fastifyInstance.server.address() - - const client2 = net.createConnection({ port, host: '127.0.0.1' }, () => { - client2.write('GET / HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n') - client2.once('data', data => { - assert.match(data.toString(), /Connection:\s*keep-alive/i) - assert.match(data.toString(), /200 OK/i) - assert.match(data.toString(), /\{"path":"\/"}/i) - - setTimeout(() => { - client2.write('GET / HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n') - client2.once('data', data => { - assert.match(data.toString(), /Connection:\s*close/i) - assert.match(data.toString(), /200 OK/i) - - // Test that fastify closes the TCP connection - client2.once('close', () => { - assert.pass() - }) - }) - }, 1000) - }) + }, 1000) }) + }) - const client1 = net.createConnection({ port, host: '127.0.0.1' }, () => { - client1.write('GET /close HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n') + const client1 = net.createConnection({ port, host: '127.0.0.1' }, () => { + client1.write('GET /close HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n') - client1.once('data', data => { - assert.match(data.toString(), /Connection:\s*keep-alive/i) - assert.match(data.toString(), /200 OK/i) + client1.once('data', data => { + assert.match(data.toString(), /Connection:\s*keep-alive/i) + assert.match(data.toString(), /200 OK/i) - // Test that fastify closes the TCP connection - client1.once('close', () => { - assert.pass() - }) + // Test that fastify closes the TCP connection + client1.once('close', () => { + assert.pass() }) }) - } - ) - }) + }) + } + ) +}) - test('Current idle connection close after server close "connection: close" header - return503OnClosing: false', assert => { - assert.plan(3) - launch('./tests/modules/immediate-close-module', {}).then( - (fastifyInstance) => { - const { port } = fastifyInstance.server.address() +test('Current idle connection close after server close "connection: close" header - return503OnClosing: false', assert => { + assert.plan(3) + launch('./tests/modules/immediate-close-module', {}).then( + (fastifyInstance) => { + const { port } = fastifyInstance.server.address() - const client = net.createConnection({ port, host: '127.0.0.1' }, () => { - client.write('GET /close HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n') + const client = net.createConnection({ port, host: '127.0.0.1' }, () => { + client.write('GET /close HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n') - client.once('data', data => { - assert.match(data.toString(), /Connection:\s*keep-alive/i) - assert.match(data.toString(), /200 OK/i) + client.once('data', data => { + assert.match(data.toString(), /Connection:\s*keep-alive/i) + assert.match(data.toString(), /200 OK/i) - // Test that fastify force close of the TCP connection - client.once('close', () => { - assert.pass() - }) + // Test that fastify force close of the TCP connection + client.once('close', () => { + assert.pass() }) }) - } - ) - }) -} + }) + } + ) +}) test('Current opened connection should not accept new incoming connections', assert => { launch('./tests/modules/immediate-close-module', {}).then( @@ -754,29 +503,6 @@ test('Current opened connection should not accept new incoming connections', ass ) }) -test('should wait at least 1 sec before closing the process', assert => { - const WAIT_BEFORE_SERVER_CLOSE_SEC = 1 - const child = spawn( - './bin/cli.js', - ['tests/modules/correct-module.js'], - { env: { ...process.env, WAIT_BEFORE_SERVER_CLOSE_SEC } } - ) - - let closedDate = null - child.on('close', () => { - closedDate = new Date() - }) - - child.stdout.on('data', () => { - const killedDate = new Date() - child.kill('SIGTERM') - setTimeout(() => { - assert.ok(closedDate.getTime() - killedDate.getTime() > WAIT_BEFORE_SERVER_CLOSE_SEC * 1000) - assert.end() - }, (WAIT_BEFORE_SERVER_CLOSE_SEC * 1000) + 500) - }) -}) - test('path with and without trailing slash', async assert => { const options = { logLevel: 'silent', diff --git a/tests/modules/custom-metrics-with-options.js b/tests/modules/custom-metrics-with-options.js index 7cb1928..8007d32 100644 --- a/tests/modules/custom-metrics-with-options.js +++ b/tests/modules/custom-metrics-with-options.js @@ -18,8 +18,17 @@ 'use strict' -module.exports = async function plugin(fastify) { - fastify.get('/', { schema: { querystring: { label: { type: 'string' } } } }, function returnConfig(request, reply) { +const schema = { + querystring: { + type: 'object', + properties: { + label: { type: 'string' }, + }, + }, +} + +module.exports = async function plugin(/** @type {import('fastify').FastifyInstance} fastify */ fastify) { + fastify.get('/', { schema }, function returnConfig(request, reply) { const { label } = request.query if (label) { this.customMetrics.collectionInvocation.inc({ label }) diff --git a/tests/modules/custom-metrics.js b/tests/modules/custom-metrics.js index 098f86a..8a3dc87 100644 --- a/tests/modules/custom-metrics.js +++ b/tests/modules/custom-metrics.js @@ -1,3 +1,4 @@ +/* eslint-disable valid-jsdoc */ /* * Copyright 2019 Mia srl * @@ -18,9 +19,19 @@ 'use strict' -module.exports = async function plugin(fastify) { - fastify.get('/', { schema: { querystring: { label: { type: 'string' } } } }, function returnConfig(request, reply) { +const schema = { + querystring: { + type: 'object', + properties: { + label: { type: 'string' }, + }, + }, +} + +module.exports = async function plugin(/** @type {import('fastify').FastifyInstance} fastify */ fastify) { + fastify.get('/', { schema }, function returnConfig(request, reply) { const { label } = request.query + if (label) { this.customMetrics.collectionInvocation.inc({ label }) } else { diff --git a/tests/modules/module-with-transform-schema.js b/tests/modules/module-with-transform-schema.js index 33deb74..9086b09 100644 --- a/tests/modules/module-with-transform-schema.js +++ b/tests/modules/module-with-transform-schema.js @@ -1,20 +1,21 @@ 'use strict' -module.exports = async function plugin(fastify) { - fastify.get('/', { - schema: { - querystring: { - type: 'object', - properties: { - label: { type: 'string' }, - }, - }, +const schema = { + querystring: { + type: 'object', + properties: { + label: { type: 'string' }, }, - }, function returnConfig(request, reply) { + }, +} + +module.exports = async function plugin(/** @type {import('fastify').FastifyInstance} fastify */ fastify) { + fastify.get('/', { schema }, function returnConfig(request, reply) { reply.send({ }) }) } +// eslint-disable-next-line no-shadow module.exports.transformSchemaForSwagger = ({ schema, url } = {}) => { if (!schema) { return { diff --git a/tests/options-extractors.test.js b/tests/options-extractors.test.js index 2964215..0ccf527 100644 --- a/tests/options-extractors.test.js +++ b/tests/options-extractors.test.js @@ -29,8 +29,10 @@ test('Test Fastify server options generator', assert => { const fastifyOptions = exportFastifyOptions(moduleOptions) assert.strictSame({ return503OnClosing: false, - ignoreTrailingSlash: false, - caseSensitive: true, + routerOptions: { + ignoreTrailingSlash: false, + caseSensitive: true, + }, requestIdHeader: 'x-request-id', pluginTimeout: 30000, bodyLimit: moduleOptions.bodyLimit, @@ -52,8 +54,10 @@ test('Test Fastify server options overwriting', assert => { const fastifyOptions = exportFastifyOptions(moduleOptions) assert.strictSame({ return503OnClosing: true, - ignoreTrailingSlash: true, - caseSensitive: true, + routerOptions: { + ignoreTrailingSlash: true, + caseSensitive: true, + }, requestIdHeader: 'x-request-id', pluginTimeout: 42, bodyLimit: Number.MAX_SAFE_INTEGER, From 2a544193614efae8b353c7755a12ffe393c48ee3 Mon Sep 17 00:00:00 2001 From: "demetrio.marino" Date: Thu, 26 Feb 2026 18:16:23 +0100 Subject: [PATCH 02/15] feat(ci): tests on node v22, v24 --- .github/workflows/node.js.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index c0bd428..49fcd87 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -20,7 +20,7 @@ jobs: strategy: matrix: - node-version: [18.x, 20.x, 22.x] + node-version: [22.x, 24.x] steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 From 71a6de1f1713e382a4138b7ce9c627d3fe635ff5 Mon Sep 17 00:00:00 2001 From: "demetrio.marino" Date: Thu, 26 Feb 2026 18:18:38 +0100 Subject: [PATCH 03/15] feat(ci): tests on node v22, v24 --- .github/workflows/node.js.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index c0bd428..49fcd87 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -20,7 +20,7 @@ jobs: strategy: matrix: - node-version: [18.x, 20.x, 22.x] + node-version: [22.x, 24.x] steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 From c3341338c48e6291342beeb75f8706a9718e21ba Mon Sep 17 00:00:00 2001 From: "demetrio.marino" Date: Thu, 26 Feb 2026 18:19:03 +0100 Subject: [PATCH 04/15] 9.0.0-rc.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 69c5a10..166b235 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@mia-platform/lc39", - "version": "8.0.2", + "version": "9.0.0-rc.0", "description": "The Mia-Platform Node.js service launcher", "keywords": [ "cli", From 4191079e0f183d2a59886b103408557d1af634bf Mon Sep 17 00:00:00 2001 From: Umberto Toniolo Date: Fri, 27 Feb 2026 10:07:39 +0100 Subject: [PATCH 05/15] chore(ci): fix publish job with missing checkout step --- .github/workflows/node.js.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index 49fcd87..5903ae4 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -43,9 +43,9 @@ jobs: needs: [build] if: ${{ startsWith(github.ref, 'refs/tags/') }} steps: - - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: - check-latest: true + show-progress: false - name: Use Node.js 24 uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 with: From 4bf67d41cefda175ecc789610f974fe7380c0a00 Mon Sep 17 00:00:00 2001 From: Umberto Toniolo Date: Fri, 27 Feb 2026 10:09:46 +0100 Subject: [PATCH 06/15] 9.0.0-rc.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 166b235..c92fd19 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@mia-platform/lc39", - "version": "9.0.0-rc.0", + "version": "9.0.0-rc.1", "description": "The Mia-Platform Node.js service launcher", "keywords": [ "cli", From 30f97fd54b2ccc3857c516778ff550287b7e88e8 Mon Sep 17 00:00:00 2001 From: Umberto Toniolo Date: Fri, 27 Feb 2026 10:14:31 +0100 Subject: [PATCH 07/15] chore(ci): add next/latest --tag flag for npm publish --- .github/workflows/node.js.yml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index 5903ae4..33c4f5a 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -53,4 +53,13 @@ jobs: node-version: 24 registry-url: 'https://registry.npmjs.org' - run: npm i - - run: npm publish --access public + - name: Determine npm publish tag + id: npm-tag + run: | + VERSION=$(node -p "require('./package.json').version") + if echo "$VERSION" | grep -qE '[-]'; then + echo "tag=next" >> "$GITHUB_OUTPUT" + else + echo "tag=latest" >> "$GITHUB_OUTPUT" + fi + - run: npm publish --access public --tag ${{ steps.npm-tag.outputs.tag }} From f46024122a0de6723cc5c8a385ed60e72863dece Mon Sep 17 00:00:00 2001 From: Umberto Toniolo Date: Fri, 27 Feb 2026 10:14:35 +0100 Subject: [PATCH 08/15] 9.0.0-rc.2 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index c92fd19..92cca29 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@mia-platform/lc39", - "version": "9.0.0-rc.1", + "version": "9.0.0-rc.2", "description": "The Mia-Platform Node.js service launcher", "keywords": [ "cli", From 6bc675e0d00da77988bd5304bf03897993ec2a36 Mon Sep 17 00:00:00 2001 From: Umberto Toniolo Date: Fri, 27 Feb 2026 10:29:39 +0100 Subject: [PATCH 09/15] chore(ci): update publish step with secrets --- .github/workflows/node.js.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index 33c4f5a..98ab56a 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -51,7 +51,7 @@ jobs: with: check-latest: true node-version: 24 - registry-url: 'https://registry.npmjs.org' + registry-url: ${{ secrets.MIA_NPM_REGISTRY_URL }} - run: npm i - name: Determine npm publish tag id: npm-tag @@ -63,3 +63,5 @@ jobs: echo "tag=latest" >> "$GITHUB_OUTPUT" fi - run: npm publish --access public --tag ${{ steps.npm-tag.outputs.tag }} + env: + NODE_AUTH_TOKEN: ${{ secrets.MIA_NPM_REGISTRY_TOKEN }} From f18f161ba6a148c7e760e2415eddb6db1ea47376 Mon Sep 17 00:00:00 2001 From: Umberto Toniolo Date: Fri, 27 Feb 2026 10:32:09 +0100 Subject: [PATCH 10/15] 9.0.0-rc.3 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 92cca29..92c724a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@mia-platform/lc39", - "version": "9.0.0-rc.2", + "version": "9.0.0-rc.3", "description": "The Mia-Platform Node.js service launcher", "keywords": [ "cli", From 679417bd6350c5dcd536428f50824cbb5a537052 Mon Sep 17 00:00:00 2001 From: Umberto Toniolo Date: Fri, 27 Feb 2026 10:40:59 +0100 Subject: [PATCH 11/15] chore(ci): reset url for npm registry --- .github/workflows/node.js.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index 98ab56a..ce8fd95 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -51,7 +51,7 @@ jobs: with: check-latest: true node-version: 24 - registry-url: ${{ secrets.MIA_NPM_REGISTRY_URL }} + registry-url: https://registry.npmjs.org - run: npm i - name: Determine npm publish tag id: npm-tag From 096dc6419ad3f15947d1037bac3b7ed30766bc29 Mon Sep 17 00:00:00 2001 From: Umberto Toniolo Date: Fri, 27 Feb 2026 10:41:04 +0100 Subject: [PATCH 12/15] 9.0.0-rc.4 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 92c724a..2e72989 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@mia-platform/lc39", - "version": "9.0.0-rc.3", + "version": "9.0.0-rc.4", "description": "The Mia-Platform Node.js service launcher", "keywords": [ "cli", From b9199fd6cec3e76f4e4bdd8fd0333f6021181e74 Mon Sep 17 00:00:00 2001 From: Umberto Toniolo Date: Fri, 27 Feb 2026 10:52:44 +0100 Subject: [PATCH 13/15] chore(ci): update secret --- .github/workflows/node.js.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index ce8fd95..475de98 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -64,4 +64,4 @@ jobs: fi - run: npm publish --access public --tag ${{ steps.npm-tag.outputs.tag }} env: - NODE_AUTH_TOKEN: ${{ secrets.MIA_NPM_REGISTRY_TOKEN }} + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} From cb8782a1ded956b88e73ad82bfe39e179f377d24 Mon Sep 17 00:00:00 2001 From: Umberto Toniolo Date: Fri, 27 Feb 2026 10:52:50 +0100 Subject: [PATCH 14/15] 9.0.0-rc.5 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 2e72989..349f170 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@mia-platform/lc39", - "version": "9.0.0-rc.4", + "version": "9.0.0-rc.5", "description": "The Mia-Platform Node.js service launcher", "keywords": [ "cli", From 259e71bd0b3033ca205e5400f6ff8bf98276f072 Mon Sep 17 00:00:00 2001 From: "demetrio.marino" Date: Tue, 3 Mar 2026 08:46:58 +0100 Subject: [PATCH 15/15] feat: updates on CHANGELOG and README --- CHANGELOG.md | 3 ++- README.md | 16 +++++++++------- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index baa26b4..9941286 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -- migrate to Fastify v5 +- `lc39` now supports NodeJS from v22 and above +- Migrate to Fastify@5 and related dependencies. While there are no breaking changes in this library, you might want to get more details about the changes by checking the [Fastify Migration Guide](https://www.fastify.io/docs/latest/Guides/Migration-Guide-V5/) ### Fixed diff --git a/README.md b/README.md index eb21f2a..373be07 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ # Launch Complex 39 [![Node.js CI][action-status-svg]][github-action] -[![javascript style guide][standard-mia-svg]][standard-mia] +[![javascript style guide][standard-mia-svg]][standard-mia] [![Coverage Status][coverall-svg]][coverall-io] [![NPM version][npmjs-svg]][npmjs-com] @@ -24,13 +24,15 @@ To install the package you can run: npm install @mia-platform/lc39 --save ``` -It is possible to install the next version of the package, which use fastify v3. The version is a release candidate, -so it is not yet a stable version and should not be used in production environments (next updates could be breaking). -To try it, you can run: +The following table shows the supported versions of Fastify for each version of lc39: -```sh -npm install @mia-platform/lc39@next --save -``` +| lc39 version | Fastify version | +|--------------|-----------------| +| v9 | 5.x.x | +| v8 | 4.x.x | +| v6 | 3.x.x | + +You can check the [CHANGELOG](./CHANGELOG.md) for more details about the changes in each version. We recommend to install the module locally on every one of your project to be able to update them indipendently one from the other. To use the locally installed instance you