Large Hono apps are built from smaller Hono apps. All three composition tools -- app.route(), app.basePath(), and app.mount() -- live in src/hono-base.ts and reduce to the same primitive: #addRoute prefixing paths into a single flat route table. There is no nested dispatch at request time.
Every registration funnels through #addRoute
#addRoute(method: string, path: string, handler: H, baseRoutePath?: string): void {
method = method.toUpperCase()
path = mergePath(this._basePath, path)
const r: RouterRoute = {
basePath:
baseRoutePath !== undefined ? mergePath(this._basePath, baseRoutePath) : this._basePath,
path,
method,
handler,
}
this.router.add(method, path, [handler, r])
this.routes.push(r)
}
The instance's _basePath is baked into the stored path at registration time. app.routes keeps the human-readable record (used by app.request introspection, the hono/dev helper, and the RPC type surface).
basePath returns a shallow clone
basePath does not mutate the app -- it clones it, sharing the router, the routes array, and the handlers, but with a different _basePath:
basePath<SubPath extends string>(path: SubPath) {
const subApp = this.#clone()
subApp._basePath = mergePath(this._basePath, path)
return subApp
}
Because the clone shares this.routes and this.router, routes registered on either object land in the same table -- only the prefix differs. const api = new Hono().basePath('/api') is therefore free at request time; it is pure registration-time bookkeeping.
route() replays a sub-app's routes
app.route('/api', subApp) iterates the sub-app's recorded routes and re-adds each one onto a basePath-clone of the parent. One subtlety: if the sub-app had its own onError handler, each transplanted handler is wrapped so that the sub-app's error handler still applies to its own routes:
route(path, app) {
const subApp = this.basePath(path)
app.routes.map((r) => {
let handler
if (app.errorHandler === errorHandler) {
handler = r.handler
} else {
handler = async (c: Context, next: Next) =>
(await compose([], app.errorHandler)(c, () => r.handler(c, next))).res
;(handler as any)[COMPOSED_HANDLER] = r.handler
}
subApp.#addRoute(r.method, r.path, handler, r.basePath)
})
return this
}
After route() returns, the sub-app object is no longer involved in dispatch; its routes are the parent's routes. This is why route grouping in Hono has no per-request cost.
mount() hosts non-Hono handlers
mount bridges to any (request, ...args) => Response function -- another framework, or a bare fetch handler. It registers a single catch-all middleware under the mount point that rewrites the URL to strip the prefix, then delegates:
replaceRequest ||= (() => {
const mergedPath = mergePath(this._basePath, path)
const pathPrefixLength = mergedPath === '/' ? 0 : mergedPath.length
return (request) => {
const url = new URL(request.url)
url.pathname = this.getPath(request).slice(pathPrefixLength) || '/'
return new Request(url, request)
}
})()
const handler: MiddlewareHandler = async (c, next) => {
const res = await applicationHandler(replaceRequest(c.req.raw), ...getOptions(c))
if (res) {
return res
}
await next()
}
this.#addRoute(METHOD_NAME_ALL, mergePath(path, '*'), handler)
If the mounted application returns nothing, Hono falls through to its own routes (await next()), so a mount point can coexist with native routes. By default the mounted handler also receives c.env and the execution context as extra arguments; optionHandler and replaceRequest: false override both behaviors.
Related
- Registration order and how the flat table is matched: Routing
- The
strictandgetPathoptions that affect path normalization before matching: Configuration