hono icon indicating copy to clipboard operation
hono copied to clipboard

HMR for server routes

Open rtritto opened this issue 1 year ago • 11 comments

What is the feature you are proposing?

During development environment, HMR (Hot Module Replacement) is needed to develop: after the server load all route handlers, any change to to handler must be updated to the server.

HMR improves the DX (Developer Experience) without manually restart the server every time a file is changed.

Example with vite

Having a file handler:

// /api/index.ts
export default (c) => {
  return c.text('Ok')
}

and the server file:

// /server.ts
import { serve } from '@hono/node-server'
import { Hono } from 'hono'

const filepath = './api/index.ts'

const registerRoute = async () => {
  // Register the file to intercept changes
  const { default: handler } = await viteDevServer.ssrLoadModule(filepath, { fixStacktrace: true })
  app.get('/', handler)
}

const app = new Hono()

// Register route first time
await registerRoute()

// Event for file changes
viteDevServer.watcher.on('change', async (file) => {
  // Re-register route with the new file handler
  await registerRoute()
})

serve({ fetch: app.fetch })

Actual

On re-register an handler, Hono routers throws an Error:

Error: Can not add a route since the matcher is already built.
    at SmartRouter.add (file:///C:/Users/<USER>/AppData/Local/Yarn/Berry/cache/hono-npm-4.6.16-e1b105c322-10c0.zip/node_modules/hono/dist/router/smart-router/router.js:12:13)
    at Hono.#addRoute (file:///C:/Users/<USER>/AppData/Local/Yarn/Berry/cache/hono-npm-4.6.16-e1b105c322-10c0.zip/node_modules/hono/dist/hono-base.js:157:17) 
    at file:///C:/Users/<USER>/AppData/Local/Yarn/Berry/cache/hono-npm-4.6.16-e1b105c322-10c0.zip/node_modules/hono/dist/hono-base.js:42:25
    at Array.forEach (<anonymous>)
    at Hono.<computed> [as get] (file:///C:/Users/<USER>/AppData/Local/Yarn/Berry/cache/hono-npm-4.6.16-e1b105c322-10c0.zip/node_modules/hono/dist/hono-base.js:41:14)
    at file:///C:/<PROJECT>/.yarn/unplugged/universal-autorouter-npm-0.2.3-5b1bcaec9b/node_modules/universal-autorouter/dist/index.mjs:62:20

Expected

On a re-register route, Hono routers should replace the old handler with the new one.

Implementation (draft)

Some points that can be achieved one or all to the check of Hono routers:

  • add process.env.NODE_ENV === 'production' to condition of the check. Eg:

    import { Hono } from 'hono'
    
    const app = new Hono()
    
    app.get('/', (c) => c.text('foo'))
    
    await app.request('/')
    
    app.get('/', (c) => c.text('foo')) // No Error in development / Error in production
    
  • the error is provided only for new routes (check current method and route with the map of routes). Eg:

    import { Hono } from 'hono'
    
    const app = new Hono()
    
    app.get('/', (c) => c.text('foo'))
    
    await app.request('/')
    
    app.get('/', (c) => c.text('foo')) // No Error
    
    app.get('/extra', (c) => c.text('foo')) // Error! (expected)
    

Initial discussion: #3805

rtritto avatar Jan 09 '25 18:01 rtritto

Draft for implementation of /src/router/smart-router/router.ts:

export class SmartRouter<T> implements Router<T> {
  name: string = 'SmartRouter'
  #routers: Router<T>[] = []
-  #routes?: [string, string, T][] = []
+  #routes?: Record<string, T> = {} 

  constructor(init: { routers: Router<T>[] }) {
    this.#routers = init.routers
  }

  add(method: string, path: string, handler: T) {
    if (!this.#routes) {
      throw new Error(MESSAGE_MATCHER_IS_ALREADY_BUILT)
    }

-    this.#routes.push([method, path, handler])
+    this.#routes[`${path}${method}`] = handler
  }

  match(method: string, path: string): Result<T> {
    if (!this.#routes) {
      throw new Error('Fatal error')
    }

    const routers = this.#routers
    const routes = this.#routes

    const len = routers.length
    let i = 0
    let res
    for (; i < len; i++) {
      const router = routers[i]
      try {
-        for (let i = 0, len = routes.length; i < len; i++) {
-          router.add(...routes[i])
+        for (const [key, handler] of Object.entries(routes)) {
+          const [path, method] = key.split('')
+          router.add(method, path, handler) 
        }
        res = router.match(method, path)
      } catch (e) {
        if (e instanceof UnsupportedPathError) {
          continue
        }
        throw e
      }

      this.match = router.match.bind(router)
      this.#routers = [router]
-      this.#routes = undefined
      break
    }

rtritto avatar Jan 09 '25 23:01 rtritto

@rtritto

Have you ever tried @hono/vite-dev-server? It can HMR as a dev-server for Vite.

yusukebe avatar Jan 10 '25 00:01 yusukebe

@yusukebe Yes, I did some try but got some trouble (vite config conflicts) with Vike ecosystem. @hono/vite-dev-server doesn't use filesystem to load routes and uses hot-reload (inject to fetch-client) instead of HMR (inject to server). I also did some look to code and didn't find anything useful to this use case (only viteDevServer.ssrLoadModule is used).

How can an updated handler be injected in Hono context to replace the handler of an already regirtered route?

rtritto avatar Jan 10 '25 12:01 rtritto

I created a simple register object (hmrRoutes) that will be dispatched on related routes.

// /server.ts
import { serve } from '@hono/node-server'
import { Hono } from 'hono'

const filepath = './api/index.ts'

const app = new Hono()

// Register the file to intercept changes
const { default: handler } = await viteDevServer.ssrLoadModule(filepath, { fixStacktrace: true })
// Register route first time
app.get('/', handler)

// Inject section
app.hmrRoutes = {}
app.injectHandler = function (method, route, handler) {
  app.hmrRoutes[`${method}${route}`] = handler
}

// Hook into the dispatch mechanism to process dynamic routes
const originalDispatch = app.dispatch
app.dispatch = async function (context) {
  const { method, routePath } = context.req
  const handler = app.hmrRoutes[`${method}${routePath}`]
  if (handler) {
    // Dispatch with re-registered hander
    return await handler(context)
  }
  // Default dispatch
  return originalDispatch.call(this, context)
}

// Event for file changes
viteDevServer.watcher.on('change', async (file) => {
  const { default: handler } = await viteDevServer.ssrLoadModule(filepath, { fixStacktrace: true })
  // <Code to get method and route values>

  // Re-register route with the new file handler
  app.injectHandler(method, route, handler)
})

serve({ fetch: app.fetch })

@yusukebe why the dispatch function isn't called after a request?

rtritto avatar Jan 11 '25 01:01 rtritto

@rtritto

That is the limitation of the routers. You can't do it.

yusukebe avatar Jan 11 '25 07:01 yusukebe

@yusukebe I fixed using a middleware and now it works!

// /server.ts
import { serve } from '@hono/node-server'
import { Hono } from 'hono'

const filepath = './api/index.ts'

const app = new Hono()

// Middleware to dispatch with re-registered handler
// Call before register routes
app.use(`*`, async (c, next) => {
  const handler = app.hmrRoutes[`${c.env.incoming.method}${c.env.incoming.url}`]
  if (handler) {
    return await handler(c)
  }
  await next()
})

// Register the file to intercept changes
const { default: handler } = await viteDevServer.ssrLoadModule(filepath, { fixStacktrace: true })
// Register route
app.get('/', handler)

app.hmrRoutes = {}
app.injectHandler = function (method, route, handler) {
  // Inject route with new handler
  app.hmrRoutes[`${method.toUpperCase()}${route}`] = handler
}

// Event for file changes
viteDevServer.watcher.on('change', async (file) => {
  const { default: handler } = await viteDevServer.ssrLoadModule(filepath, { fixStacktrace: true })
  // <Code to get method and route values>

  // Re-register route with the new file handler
  app.injectHandler(method, route, handler)
})

serve({ fetch: app.fetch })

rtritto avatar Jan 11 '25 13:01 rtritto

Having the code, could you create a new official package for Hono inside @hono/vite-plugins? Useful for community and developers. ~~If not, I will create my own package.~~ I created universal-autorouter-hono.

rtritto avatar Jan 11 '25 13:01 rtritto

Can you integrate this package: https://github.com/Julien-R44/hot-hook . Adonisjs is using it for HMR

tombohub avatar Jan 31 '25 09:01 tombohub

Can you integrate this package: https://github.com/Julien-R44/hot-hook . Adonisjs is using it for HMR

Why? The hot-hook can replace the function handler of routes in Hono app?

rtritto avatar Feb 12 '25 10:02 rtritto

As alternative/workaround, I'm using the REST-as-RPC approach with less config/code/dependencies/dev-dependencies.

Like said in comment, the advantages are:

  • after a route handler file is changed, the server is automatically restarted (no HMR needed)
  • API files are directly bundled (no vite-plugin-build-routes needed) with static imports (imported in the server file) and the default export (exported by the index file of the APIs)
    • no issue with Node File Trace (eg @vercel/nft) where dynamic imported API files aren't imported (related issue)

rtritto avatar Feb 12 '25 10:02 rtritto

Maybe the changes in https://github.com/vikejs/vike-node/pull/69 can help for a Vite plugin

rtritto avatar Mar 20 '25 11:03 rtritto

@rtritto

Have you ever tried @hono/vite-dev-server? It can HMR as a dev-server for Vite.

The @hono/vite-dev-server doesn't seem to support hot module replacement (HMR) very well; you can look into this issue: https://github.com/honojs/vite-plugins/issues/274

lovetingyuan avatar Jun 30 '25 08:06 lovetingyuan