Repository: fastify/fastify (JavaScript)

Issue:
onRoute hook executed for endpoint declared before registering it
### Prerequisites

- [X] I have written a descriptive issue title
- [X] I have searched existing issues to ensure the bug has not already been reported


### Fastify version

4.0.3

### Plugin version

_No response_

### Node.js version

18

### Operating system

macOS

### Operating system version (i.e. 20.04, 11.3, 10)

latest

### Description

When the `onRoute` hook is registered after a GET handler, it is executed for the HEAD route only.


### Steps to Reproduce

```
const fastify = require('fastify')
const app = fastify()

app.addHook('onRoute', function inspector(routeOptions) {
  console.log('before ' + routeOptions.method)
})

app.get('/', function (request, reply) { })

app.addHook('onRoute', function inspector(routeOptions) {
  console.log('after ' + routeOptions.method)
})

app.ready()
```

Prints:

```
before GET
before HEAD
after HEAD
```

### Expected Behavior

I think the after hook should not be executed.

It should print:

```
before GET
before HEAD
```


Discussion:
Agreed! Good spot!
The head route added by `fastify` is register in the `next` cycle.

The expected behavior mentioned in here require the `HEAD` route register parallel to `GET | PUT | PATCH | DELETE | OPTIONS`. Which may cause
1. Cold start time increase.
2. Register and de-register route frequently.

I don't know if there are better option to do it.
I would whether not fire the `onRoute` hook if the route is not added by user.



Patch:
diff --git a/lib/context.js b/lib/context.js
index fd0a0b72a3a..6baf0bf2f7d 100644
--- a/lib/context.js
+++ b/lib/context.js
@@ -9,7 +9,8 @@ const {
   kRequest,
   kBodyLimit,
   kLogLevel,
-  kContentTypeParser
+  kContentTypeParser,
+  kRouteByFastify
 } = require('./symbols.js')
 
 // Objects that holds the context of every request
@@ -25,7 +26,8 @@ function Context ({
   attachValidation,
   replySerializer,
   schemaErrorFormatter,
-  server
+  server,
+  isFastify
 }) {
   this.schema = schema
   this.handler = handler
@@ -50,6 +52,7 @@ function Context ({
   this.attachValidation = attachValidation
   this[kReplySerializerDefault] = replySerializer
   this.schemaErrorFormatter = schemaErrorFormatter || server[kSchemaErrorFormatter] || defaultSchemaErrorFormatter
+  this[kRouteByFastify] = isFastify
 
   this.server = server
 }
diff --git a/lib/route.js b/lib/route.js
index 8c26b18a038..fbc45a418c5 100644
--- a/lib/route.js
+++ b/lib/route.js
@@ -8,7 +8,7 @@ const supportedMethods = ['DELETE', 'GET', 'HEAD', 'PATCH', 'POST', 'PUT', 'OPTI
 const { normalizeSchema } = require('./schemas')
 const { parseHeadOnSendHandlers } = require('./headRoute')
 const warning = require('./warnings')
-const { kRequestAcceptVersion } = require('./symbols')
+const { kRequestAcceptVersion, kRouteByFastify } = require('./symbols')
 
 const {
   compileSchemasForValidation,
@@ -113,7 +113,7 @@ function buildRouting (options) {
   }
 
   // Convert shorthand to extended route declaration
-  function prepareRoute ({ method, url, options, handler }) {
+  function prepareRoute ({ method, url, options, handler, isFastify }) {
     if (typeof url !== 'string') {
       throw new FST_ERR_INVALID_URL(typeof url)
     }
@@ -140,11 +140,11 @@ function buildRouting (options) {
       handler: handler || (options && options.handler)
     })
 
-    return route.call(this, { options })
+    return route.call(this, { options, isFastify })
   }
 
   // Route management
-  function route ({ options }) {
+  function route ({ options, isFastify }) {
     // Since we are mutating/assigning only top level props, it is fine to have a shallow copy using the spread operator
     const opts = { ...options }
 
@@ -176,30 +176,30 @@ function buildRouting (options) {
     if (path === '/' && prefix.length > 0 && opts.method !== 'HEAD') {
       switch (opts.prefixTrailingSlash) {
         case 'slash':
-          addNewRoute.call(this, { path })
+          addNewRoute.call(this, { path, isFastify })
           break
         case 'no-slash':
-          addNewRoute.call(this, { path: '' })
+          addNewRoute.call(this, { path: '', isFastify })
           break
         case 'both':
         default:
-          addNewRoute.call(this, { path: '' })
+          addNewRoute.call(this, { path: '', isFastify })
           // If ignoreTrailingSlash is set to true we need to add only the '' route to prevent adding an incomplete one.
           if (ignoreTrailingSlash !== true && (ignoreDuplicateSlashes !== true || !prefix.endsWith('/'))) {
-            addNewRoute.call(this, { path, prefixing: true })
+            addNewRoute.call(this, { path, prefixing: true, isFastify })
           }
       }
     } else if (path[0] === '/' && prefix.endsWith('/')) {
       // Ensure that '/prefix/' + '/route' gets registered as '/prefix/route'
-      addNewRoute.call(this, { path: path.slice(1) })
+      addNewRoute.call(this, { path: path.slice(1), isFastify })
     } else {
-      addNewRoute.call(this, { path })
+      addNewRoute.call(this, { path, isFastify })
     }
 
     // chainable api
     return this
 
-    function addNewRoute ({ path, prefixing = false }) {
+    function addNewRoute ({ path, prefixing = false, isFastify = false }) {
       const url = prefix + path
 
       opts.url = url
@@ -241,7 +241,8 @@ function buildRouting (options) {
         attachValidation: opts.attachValidation,
         schemaErrorFormatter: opts.schemaErrorFormatter,
         replySerializer: this[kReplySerializerDefault],
-        server: this
+        server: this,
+        isFastify
       })
 
       if (opts.version) {
@@ -249,13 +250,20 @@ function buildRouting (options) {
         constraints.version = opts.version
       }
 
-      const headRouteExists = opts.method === 'HEAD' && router.find(opts.method, opts.url, constraints) != null
+      const headHandler = router.find('HEAD', opts.url, constraints)
+      const hasHEADHandler = headHandler != null
 
-      // Check if the current route is not for a sibling HEAD one
-      if (!headRouteExists) {
-        try {
-          router.on(opts.method, opts.url, { constraints }, routeHandler, context)
-        } catch (error) {
+      // remove the head route created by fastify
+      if (hasHEADHandler && !context[kRouteByFastify] && headHandler.store[kRouteByFastify]) {
+        router.off(opts.method, opts.url, { constraints })
+      }
+
+      try {
+        router.on(opts.method, opts.url, { constraints }, routeHandler, context)
+      } catch (error) {
+        // any route insertion error created by fastify can be safely ignore
+        // because it only duplicate route for head
+        if (!context[kRouteByFastify]) {
           const isDuplicatedRoute = error.message.includes(`Method '${opts.method}' already declared for route '${opts.url}'`)
           if (isDuplicatedRoute) {
             throw new FST_ERR_DUPLICATED_ROUTE(opts.method, opts.url)
@@ -320,19 +328,21 @@ function buildRouting (options) {
           }
         })
 
-        const { exposeHeadRoute } = opts
-        const hasRouteExposeHeadRouteFlag = exposeHeadRoute != null
-        const shouldExposeHead = hasRouteExposeHeadRouteFlag ? exposeHeadRoute : globalExposeHeadRoutes
-
-        if (shouldExposeHead && options.method === 'GET' && !headRouteExists) {
-          const onSendHandlers = parseHeadOnSendHandlers(opts.onSend)
-          prepareRoute.call(this, { method: 'HEAD', url: path, options: { ...opts, onSend: onSendHandlers } })
-        } else if (headRouteExists && exposeHeadRoute) {
-          warning.emit('FSTDEP007')
-        }
-
         done(notHandledErr)
       })
+
+      // register head route in sync
+      // we must place it after the `this.after`
+      const { exposeHeadRoute } = opts
+      const hasRouteExposeHeadRouteFlag = exposeHeadRoute != null
+      const shouldExposeHead = hasRouteExposeHeadRouteFlag ? exposeHeadRoute : globalExposeHeadRoutes
+
+      if (shouldExposeHead && options.method === 'GET' && !hasHEADHandler) {
+        const onSendHandlers = parseHeadOnSendHandlers(opts.onSend)
+        prepareRoute.call(this, { method: 'HEAD', url: path, options: { ...opts, onSend: onSendHandlers }, isFastify: true })
+      } else if (hasHEADHandler && exposeHeadRoute) {
+        warning.emit('FSTDEP007')
+      }
     }
   }
 
diff --git a/lib/symbols.js b/lib/symbols.js
index 1b8b2c5feb4..de534e29eb5 100644
--- a/lib/symbols.js
+++ b/lib/symbols.js
@@ -47,7 +47,8 @@ const keys = {
   kTestInternals: Symbol('fastify.testInternals'),
   kErrorHandler: Symbol('fastify.errorHandler'),
   kHasBeenDecorated: Symbol('fastify.hasBeenDecorated'),
-  kKeepAliveConnections: Symbol('fastify.keepAliveConnections')
+  kKeepAliveConnections: Symbol('fastify.keepAliveConnections'),
+  kRouteByFastify: Symbol('fastify.routeByFastify')
 }
 
 module.exports = keys


Test patch:
diff --git a/test/hooks.test.js b/test/hooks.test.js
index 8f26c8f70fd..eef4173374e 100644
--- a/test/hooks.test.js
+++ b/test/hooks.test.js
@@ -799,6 +799,27 @@ test('onRoute hook with many prefix', t => {
   fastify.ready(err => { t.error(err) })
 })
 
+test('onRoute hook should not be called when it registered after route', t => {
+  t.plan(3)
+  const fastify = Fastify()
+
+  fastify.addHook('onRoute', () => {
+    t.pass()
+  })
+
+  fastify.get('/', function (req, reply) {
+    reply.send()
+  })
+
+  fastify.addHook('onRoute', () => {
+    t.fail('should not be called')
+  })
+
+  fastify.ready(err => {
+    t.error(err)
+  })
+})
+
 test('onResponse hook should log request error', t => {
   t.plan(4)
 
diff --git a/test/pretty-print.test.js b/test/pretty-print.test.js
index 0d5c7cdb82c..47ec61f8e11 100644
--- a/test/pretty-print.test.js
+++ b/test/pretty-print.test.js
@@ -144,7 +144,7 @@ test('pretty print - commonPrefix', t => {
 `
     const flatExpected = `└── / (-)
     ├── helicopter (GET, HEAD)
-    └── hello (GET, PUT, HEAD)
+    └── hello (GET, HEAD, PUT)
 `
     t.equal(typeof radixTree, 'string')
     t.equal(typeof flatTree, 'string')
@@ -200,7 +200,7 @@ test('pretty print - includeMeta, includeHooks', t => {
     │   • (onTimeout) ["onTimeout()"]
     │   • (onRequest) ["anonymous()"]
     │   • (errorHandler) "defaultErrorHandler()"
-    └── hello (GET, PUT, HEAD)
+    └── hello (GET, HEAD, PUT)
         • (onTimeout) ["onTimeout()"]
         • (onRequest) ["anonymous()"]
         • (errorHandler) "defaultErrorHandler()"
@@ -210,7 +210,7 @@ test('pretty print - includeMeta, includeHooks', t => {
     ├── helicopter (GET, HEAD)
     │   • (onTimeout) ["onTimeout()"]
     │   • (onRequest) ["anonymous()"]
-    └── hello (GET, PUT, HEAD)
+    └── hello (GET, HEAD, PUT)
         • (onTimeout) ["onTimeout()"]
         • (onRequest) ["anonymous()"]
 `