From e4f67157ac3f4e7f5bab96e29cc0e20f3347fb8b Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Tue, 12 May 2026 14:45:44 +0200 Subject: [PATCH] ref(hono): Consolidate route patching and add clarification comments --- packages/hono/src/shared/applyPatches.ts | 14 +- .../hono/src/shared/middlewareHandlers.ts | 2 + packages/hono/src/shared/patchAppUse.ts | 4 +- packages/hono/src/shared/patchRoute.ts | 34 +- .../hono/test/shared/applyPatches.test.ts | 394 ++++++++++++++++++ packages/hono/test/shared/patchAppUse.test.ts | 309 +------------- packages/hono/test/shared/patchRoute.test.ts | 141 ------- 7 files changed, 432 insertions(+), 466 deletions(-) create mode 100644 packages/hono/test/shared/applyPatches.test.ts delete mode 100644 packages/hono/test/shared/patchRoute.test.ts diff --git a/packages/hono/src/shared/applyPatches.ts b/packages/hono/src/shared/applyPatches.ts index 1b694ca7cfa5..9ad70c1dd62a 100644 --- a/packages/hono/src/shared/applyPatches.ts +++ b/packages/hono/src/shared/applyPatches.ts @@ -1,14 +1,18 @@ import type { Env, Hono } from 'hono'; -import { patchAppUse } from '../shared/patchAppUse'; -import { patchRoute } from '../shared/patchRoute'; +import { patchAppUse } from './patchAppUse'; +import { installRouteHookOnPrototype } from './patchRoute'; /** - * Applies necessary patches to the Hono app to ensure that Sentry can properly trace middleware and route handlers. + * Instruments a Hono app instance for Sentry tracing in middleware and route handlers. + * + * Two strategies are needed because Hono mixes instance fields and prototype methods: + * - `use` is a per-instance class field (instance own property) → must be patched on the instance + * - `route` is a prototype method → patched once globally, covers all instances */ export function applyPatches(app: Hono): void { // `app.use` (instance own property) — wraps middleware at registration time on this instance. patchAppUse(app); - //`HonoBase.prototype.route` — wraps sub-app middleware at mount time so that route groups (`app.route('/prefix', subApp)`) are also instrumented. - patchRoute(app); + // `route()` lives on the shared prototype and is patched once globally. + installRouteHookOnPrototype(); } diff --git a/packages/hono/src/shared/middlewareHandlers.ts b/packages/hono/src/shared/middlewareHandlers.ts index 03bb6e16da58..7f961928df49 100644 --- a/packages/hono/src/shared/middlewareHandlers.ts +++ b/packages/hono/src/shared/middlewareHandlers.ts @@ -43,6 +43,8 @@ export function responseHandler(context: Context): void { function updateSpanRouteName(isolationScope: Scope, context: Context): void { const activeSpan = getActiveSpan(); + + // Final matched route: https://hono.dev/docs/helpers/route#using-with-index-parameter const lastMatchedRoute = routePath(context, -1); if (activeSpan) { diff --git a/packages/hono/src/shared/patchAppUse.ts b/packages/hono/src/shared/patchAppUse.ts index c0d620692278..8a6c4f46c40a 100644 --- a/packages/hono/src/shared/patchAppUse.ts +++ b/packages/hono/src/shared/patchAppUse.ts @@ -2,7 +2,9 @@ import { wrapMiddlewareWithSpan } from './wrapMiddlewareSpan'; import type { Env, Hono, MiddlewareHandler } from 'hono'; /** - * Patches the Hono app so that middleware is automatically traced as Sentry spans. + * Patches `app.use` (instance own property) on a Hono instance to instrument middleware at registration time. + * + * Must be per-instance because `use` is a class field, not a prototype method. */ export function patchAppUse(app: Hono): void { app.use = new Proxy(app.use, { diff --git a/packages/hono/src/shared/patchRoute.ts b/packages/hono/src/shared/patchRoute.ts index d3f732e30793..bdc1822dad4e 100644 --- a/packages/hono/src/shared/patchRoute.ts +++ b/packages/hono/src/shared/patchRoute.ts @@ -1,6 +1,7 @@ import { getOriginalFunction, markFunctionWrapped } from '@sentry/core'; import type { WrappedFunction } from '@sentry/core'; -import type { Env, Hono, MiddlewareHandler } from 'hono'; +import type { Hono, MiddlewareHandler } from 'hono'; +import { Hono as HonoClass } from 'hono'; import { wrapMiddlewareWithSpan } from './wrapMiddlewareSpan'; interface HonoRoute { @@ -15,18 +16,20 @@ interface HonoBaseProto { } /** - * Patches `HonoBase.prototype.route` so that when a sub-app is mounted via `app.route('/prefix', subApp)`, its middleware handlers - * are retroactively wrapped in Sentry spans before the parent copies them. + * Patches `route()` on the Hono base prototype once, globally. * - * `route` lives on the prototype (unlike `use` which is a class field) + * Wraps sub-app middleware at mount time so that `app.route('/prefix', subApp)` is traced. + * Idempotent: safe to call multiple times. */ -export function patchRoute(app: Hono): void { - const honoBaseProto = Object.getPrototypeOf(Object.getPrototypeOf(app)) as HonoBaseProto; +export function installRouteHookOnPrototype(): void { + // `route` is on the base prototype, not the concrete subclass, walk up one level + const honoBaseProto = Object.getPrototypeOf(HonoClass.prototype) as HonoBaseProto; if (!honoBaseProto || typeof honoBaseProto?.route !== 'function') { return; } - if (getOriginalFunction(honoBaseProto.route as WrappedFunction)) { + // Already patched: return + if (getOriginalFunction(honoBaseProto.route as unknown as WrappedFunction)) { return; } @@ -45,18 +48,13 @@ export function patchRoute(app: Hono): void { } /** - * Figures out which handlers in a sub-app's flat routes array are middleware (and should get a span), then wraps them. + * Identifies middleware handlers in a sub-app's flat routes array and wraps them in spans. * - * The challenge: Hono stores every handler as a plain { method, path, handler } entry. There is no "isMiddleware" flag. - * Two heuristics identify middleware: - * - * 1. Position within a group. `app.get('/path', mw, handler)` produces two entries with the same method+path. - * All but the last one must be middleware, because only middleware calls `next()` to pass control to the next handler. - * - * 2. Function arity (# of params) for method 'ALL'. Both `.use()` and `.all()` store their handlers under method 'ALL', - * so we can't use position alone to tell them apart when one is the last (or only) entry in its group. - * The deciding factor: Hono's `.use()` only accepts `(context, next)` (handlers with 2+ params). While `.all()` route - * handlers typically only accept `(context)`. + * Heuristics (since Hono has no "isMiddleware" flag): + * 1. Position: `app.get('/path', mw, handler)` produces entries with the same method+path. + * All but the LAST are middleware (they call `next()`). + * 2. Arity (# of params) for method 'ALL': `.use()` handlers always have 2+ params (context, next), + * while `.all()` route handlers typically have 1 (`context` only). * See: https://github.com/honojs/hono/blob/18fe604c8cefc2628240651b1af219692e1918c1/src/hono-base.ts#L156-L168 */ export function wrapSubAppMiddleware(routes: HonoRoute[]): void { diff --git a/packages/hono/test/shared/applyPatches.test.ts b/packages/hono/test/shared/applyPatches.test.ts new file mode 100644 index 000000000000..f5ea5a27cbc6 --- /dev/null +++ b/packages/hono/test/shared/applyPatches.test.ts @@ -0,0 +1,394 @@ +import * as SentryCore from '@sentry/core'; +import { Hono } from 'hono'; +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { applyPatches } from '../../src/shared/applyPatches'; + +vi.mock('@sentry/core', async () => { + const actual = await vi.importActual('@sentry/core'); + return { + ...actual, + startInactiveSpan: vi.fn((_opts: unknown) => ({ + setStatus: vi.fn(), + end: vi.fn(), + })), + }; +}); + +const startInactiveSpanMock = SentryCore.startInactiveSpan as ReturnType; + +const honoBaseProto = Object.getPrototypeOf(Object.getPrototypeOf(new Hono())); +const originalRoute = honoBaseProto.route; + +describe('applyPatches', () => { + beforeEach(() => { + vi.clearAllMocks(); + honoBaseProto.route = originalRoute; + }); + + afterAll(() => { + honoBaseProto.route = originalRoute; + }); + + describe('wrapSubAppMiddleware', () => { + it('does nothing when a sub-app has an empty routes array', async () => { + const app = new Hono(); + applyPatches(app); + + const emptySubApp = new Hono(); + app.route('/empty', emptySubApp); + + const res = await app.fetch(new Request('http://localhost/empty')); + expect(res.status).toBe(404); + expect(startInactiveSpanMock).not.toHaveBeenCalled(); + }); + + it('skips route entries whose handler is not a function', async () => { + const app = new Hono(); + applyPatches(app); + + const subApp = new Hono(); + subApp.get('/resource', () => new Response('ok')); + + (subApp.routes as unknown as Array<{ handler: unknown }>)[0]!.handler = 'not-a-function'; + + expect(() => app.route('/api', subApp)).not.toThrow(); + expect(startInactiveSpanMock).not.toHaveBeenCalled(); + }); + + it('treats same path with different HTTP methods as separate groups', async () => { + const app = new Hono(); + applyPatches(app); + + const subApp = new Hono(); + subApp.get('/resource', async function getHandler() { + return new Response('get'); + }); + subApp.post('/resource', async function postHandler() { + return new Response('post'); + }); + + app.route('/api', subApp); + + await app.fetch(new Request('http://localhost/api/resource', { method: 'GET' })); + await app.fetch(new Request('http://localhost/api/resource', { method: 'POST' })); + + expect(startInactiveSpanMock).not.toHaveBeenCalled(); + }); + + it('treats same HTTP method with different paths as separate groups', async () => { + const app = new Hono(); + applyPatches(app); + + const subApp = new Hono(); + subApp.get('/alpha', async function alphaHandler() { + return new Response('alpha'); + }); + subApp.get('/beta', async function betaHandler() { + return new Response('beta'); + }); + + app.route('/api', subApp); + + await app.fetch(new Request('http://localhost/api/alpha')); + await app.fetch(new Request('http://localhost/api/beta')); + + expect(startInactiveSpanMock).not.toHaveBeenCalled(); + }); + + it('wraps inline middleware for GET /alpha but not the sole handler for GET /beta', async () => { + const app = new Hono(); + applyPatches(app); + + const subApp = new Hono(); + subApp.get( + '/alpha', + async function alphaMw(_c: unknown, next: () => Promise) { + await next(); + }, + async function alphaHandler() { + return new Response('alpha'); + }, + ); + subApp.get('/beta', async function betaHandler() { + return new Response('beta'); + }); + + app.route('/api', subApp); + + await app.fetch(new Request('http://localhost/api/alpha')); + await app.fetch(new Request('http://localhost/api/beta')); + + const spanNames = startInactiveSpanMock.mock.calls.map((c: unknown[]) => (c[0] as { name: string }).name); + expect(spanNames).toHaveLength(1); + expect(spanNames).toContain('alphaMw'); + expect(spanNames).not.toContain('alphaHandler'); + expect(spanNames).not.toContain('betaHandler'); + }); + }); + + describe('route() patching', () => { + it('wraps middleware on sub-apps mounted via route()', async () => { + const app = new Hono(); + applyPatches(app); + + const subApp = new Hono(); + subApp.use(async function subMiddleware(_c: unknown, next: () => Promise) { + await next(); + }); + subApp.get('/', () => new Response('sub')); + + app.route('/sub', subApp); + + await app.fetch(new Request('http://localhost/sub')); + + expect(startInactiveSpanMock).toHaveBeenCalledWith(expect.objectContaining({ name: 'subMiddleware' })); + }); + + it('does not wrap sole route handlers on sub-apps', async () => { + const app = new Hono(); + applyPatches(app); + + const subApp = new Hono(); + subApp.get('/', () => new Response('sub')); + + app.route('/sub', subApp); + + await app.fetch(new Request('http://localhost/sub')); + + expect(startInactiveSpanMock).not.toHaveBeenCalled(); + }); + + it('does not double-wrap handlers already wrapped by patchAppUse on the main app', async () => { + const app = new Hono(); + applyPatches(app); + + app.use(async function mainMiddleware(_c: unknown, next: () => Promise) { + await next(); + }); + app.get('/', () => new Response('ok')); + + const parent = new Hono(); + parent.route('/', app); + + await parent.fetch(new Request('http://localhost/')); + + expect(startInactiveSpanMock).toHaveBeenCalledTimes(1); + expect(startInactiveSpanMock).toHaveBeenCalledWith(expect.objectContaining({ name: 'mainMiddleware' })); + }); + + it('does not patch route() twice when applyPatches is called multiple times', () => { + const app1 = new Hono(); + applyPatches(app1); + + const patchedRoute = honoBaseProto.route; + + const app2 = new Hono(); + applyPatches(app2); + + expect(honoBaseProto.route).toBe(patchedRoute); + }); + + it('stores the original route via __sentry_original__ for other libraries to unwrap', () => { + const app = new Hono(); + applyPatches(app); + + // oxlint-disable-next-line typescript/no-explicit-any + const sentryOriginal = (honoBaseProto.route as any).__sentry_original__; + expect(sentryOriginal).toBe(originalRoute); + }); + + it('wraps path-targeted .use("/path", handler) on sub-apps', async () => { + const app = new Hono(); + applyPatches(app); + + const subApp = new Hono(); + subApp.use('/admin/*', async function adminAuth(_c: unknown, next: () => Promise) { + await next(); + }); + subApp.get('/admin/dashboard', () => new Response('dashboard')); + + app.route('/api', subApp); + await app.fetch(new Request('http://localhost/api/admin/dashboard')); + + expect(startInactiveSpanMock).toHaveBeenCalledWith(expect.objectContaining({ name: 'adminAuth' })); + }); + + it('does not wrap .all() handlers with less than 2 params (they are route handlers, not middleware)', async () => { + const app = new Hono(); + applyPatches(app); + + const subApp = new Hono(); + subApp.all('/catch-all', async function allHandler() { + return new Response('catch-all'); + }); + + app.route('/api', subApp); + await app.fetch(new Request('http://localhost/api/catch-all')); + + expect(startInactiveSpanMock).not.toHaveBeenCalled(); + }); + + it('wraps .use() middleware but not .all() handlers on the same sub-app', async () => { + const app = new Hono(); + applyPatches(app); + + const subApp = new Hono(); + subApp.use(async function mw(_c: unknown, next: () => Promise) { + await next(); + }); + subApp.all('/wildcard', async function allRoute() { + return new Response('wildcard'); + }); + subApp.get('/specific', () => new Response('specific')); + + app.route('/mixed', subApp); + await app.fetch(new Request('http://localhost/mixed/wildcard')); + + const spanNames = startInactiveSpanMock.mock.calls.map((c: unknown[]) => (c[0] as { name: string }).name); + expect(spanNames).toContain('mw'); + expect(spanNames).not.toContain('allRoute'); + }); + + it('does not wrap sole .get()/.post()/.put()/.delete() handlers on sub-apps', async () => { + const app = new Hono(); + applyPatches(app); + + const subApp = new Hono(); + subApp.get('/resource', async function getHandler() { + return new Response('get'); + }); + subApp.post('/resource', async function postHandler() { + return new Response('post'); + }); + subApp.put('/resource', async function postHandler() { + return new Response('put'); + }); + subApp.delete('/resource', async function postHandler() { + return new Response('delete'); + }); + + app.route('/api', subApp); + await app.fetch(new Request('http://localhost/api/resource')); + + expect(startInactiveSpanMock).not.toHaveBeenCalled(); + }); + + it('wraps inline middleware in .get(path, mw, handler) on sub-apps', async () => { + const app = new Hono(); + applyPatches(app); + + const subApp = new Hono(); + subApp.get( + '/resource', + async function inlineMw(_c: unknown, next: () => Promise) { + await next(); + }, + async function getHandler() { + return new Response('get'); + }, + ); + + app.route('/api', subApp); + await app.fetch(new Request('http://localhost/api/resource')); + + const spanNames = startInactiveSpanMock.mock.calls.map((c: unknown[]) => (c[0] as { name: string }).name); + expect(spanNames).toContain('inlineMw'); + expect(spanNames).not.toContain('getHandler'); + }); + + it('wraps separately registered middleware for .get() on sub-apps', async () => { + const app = new Hono(); + applyPatches(app); + + const subApp = new Hono(); + subApp.get('/resource', async function separateMw(_c: unknown, next: () => Promise) { + await next(); + }); + subApp.get('/resource', async function getHandler() { + return new Response('get'); + }); + + app.route('/api', subApp); + await app.fetch(new Request('http://localhost/api/resource')); + + const spanNames = startInactiveSpanMock.mock.calls.map((c: unknown[]) => (c[0] as { name: string }).name); + expect(spanNames).toContain('separateMw'); + expect(spanNames).not.toContain('getHandler'); + }); + + it('wraps inline middleware registered via .on() on sub-apps', async () => { + const app = new Hono(); + applyPatches(app); + + const subApp = new Hono(); + subApp.on( + 'GET', + '/resource', + async function onMw(_c: unknown, next: () => Promise) { + await next(); + }, + async function onHandler() { + return new Response('on'); + }, + ); + + app.route('/api', subApp); + await app.fetch(new Request('http://localhost/api/resource')); + + const spanNames = startInactiveSpanMock.mock.calls.map((c: unknown[]) => (c[0] as { name: string }).name); + expect(spanNames).toContain('onMw'); + expect(spanNames).not.toContain('onHandler'); + }); + + it('wraps middleware in nested sub-apps (sub-app mounting another sub-app)', async () => { + const app = new Hono(); + applyPatches(app); + + const innerSub = new Hono(); + innerSub.use(async function innerMiddleware(_c: unknown, next: () => Promise) { + await next(); + }); + innerSub.get('/', () => new Response('inner')); + + const outerSub = new Hono(); + outerSub.use(async function outerMiddleware(_c: unknown, next: () => Promise) { + await next(); + }); + outerSub.route('/inner', innerSub); + + app.route('/outer', outerSub); + await app.fetch(new Request('http://localhost/outer/inner')); + + const spanNames = startInactiveSpanMock.mock.calls.map((c: unknown[]) => (c[0] as { name: string }).name); + expect(spanNames).toContain('outerMiddleware'); + expect(spanNames).toContain('innerMiddleware'); + }); + + it('handles sub-app with multiple path-targeted middleware for different paths', async () => { + const app = new Hono(); + applyPatches(app); + + const subApp = new Hono(); + subApp.use('/a/*', async function mwForA(_c: unknown, next: () => Promise) { + await next(); + }); + subApp.use('/b/*', async function mwForB(_c: unknown, next: () => Promise) { + await next(); + }); + subApp.get('/a/test', () => new Response('a')); + subApp.get('/b/test', () => new Response('b')); + + app.route('/sub', subApp); + + await app.fetch(new Request('http://localhost/sub/a/test')); + expect(startInactiveSpanMock).toHaveBeenCalledTimes(1); + expect(startInactiveSpanMock).toHaveBeenCalledWith(expect.objectContaining({ name: 'mwForA' })); + + startInactiveSpanMock.mockClear(); + + await app.fetch(new Request('http://localhost/sub/b/test')); + expect(startInactiveSpanMock).toHaveBeenCalledTimes(1); + expect(startInactiveSpanMock).toHaveBeenCalledWith(expect.objectContaining({ name: 'mwForB' })); + }); + }); +}); diff --git a/packages/hono/test/shared/patchAppUse.test.ts b/packages/hono/test/shared/patchAppUse.test.ts index ee376127baaa..a65c9c526a83 100644 --- a/packages/hono/test/shared/patchAppUse.test.ts +++ b/packages/hono/test/shared/patchAppUse.test.ts @@ -1,8 +1,7 @@ import * as SentryCore from '@sentry/core'; import { Hono } from 'hono'; -import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { patchAppUse } from '../../src/shared/patchAppUse'; -import { patchRoute } from '../../src/shared/patchRoute'; vi.mock('@sentry/core', async () => { const actual = await vi.importActual('@sentry/core'); @@ -19,18 +18,11 @@ vi.mock('@sentry/core', async () => { const startInactiveSpanMock = SentryCore.startInactiveSpan as ReturnType; const captureExceptionMock = SentryCore.captureException as ReturnType; -const honoBaseProto = Object.getPrototypeOf(Object.getPrototypeOf(new Hono())); -const originalRoute = honoBaseProto.route; - describe('patchAppUse (middleware spans)', () => { beforeEach(() => { vi.clearAllMocks(); }); - afterAll(() => { - honoBaseProto.route = originalRoute; - }); - it('wraps handlers in app.use(handler) so startInactiveSpan is called when middleware runs', async () => { const app = new Hono(); patchAppUse(app); @@ -84,7 +76,7 @@ describe('patchAppUse (middleware spans)', () => { await app.fetch(new Request('http://localhost/')); expect(startInactiveSpanMock).toHaveBeenCalledTimes(1); - const name = startInactiveSpanMock.mock.calls[0][0].name; + const name = startInactiveSpanMock.mock.calls[0]![0].name; expect(name).toMatch(''); }); }); @@ -135,12 +127,12 @@ describe('patchAppUse (middleware spans)', () => { expect(startInactiveSpanMock).toHaveBeenCalledTimes(3); const [firstCall, secondCall, thirdCall] = startInactiveSpanMock.mock.calls; - expect(firstCall[0]).toMatchObject({ op: 'middleware.hono' }); - expect(secondCall[0]).toMatchObject({ op: 'middleware.hono' }); - expect(firstCall[0].name).toMatch(''); - expect(secondCall[0].name).toBe('namedMiddleware'); - expect(thirdCall[0].name).toBe(''); - expect(firstCall[0].name).not.toBe(secondCall[0].name); + expect(firstCall![0]).toMatchObject({ op: 'middleware.hono' }); + expect(secondCall![0]).toMatchObject({ op: 'middleware.hono' }); + expect(firstCall![0].name).toMatch(''); + expect(secondCall![0].name).toBe('namedMiddleware'); + expect(thirdCall![0].name).toBe(''); + expect(firstCall![0].name).not.toBe(secondCall![0].name); }); it('preserves this context when calling the original use (Proxy forwards thisArg)', () => { @@ -163,289 +155,4 @@ describe('patchAppUse (middleware spans)', () => { expect(fakeApp._capturedThis).toBe(fakeApp); }); - - describe('route() patching (sub-app / route group support)', () => { - beforeEach(() => { - honoBaseProto.route = originalRoute; - }); - - it('wraps middleware on sub-apps mounted via route()', async () => { - const app = new Hono(); - patchAppUse(app); - patchRoute(app); - - const subApp = new Hono(); - subApp.use(async function subMiddleware(_c: unknown, next: () => Promise) { - await next(); - }); - subApp.get('/', () => new Response('sub')); - - app.route('/sub', subApp); - - await app.fetch(new Request('http://localhost/sub')); - - expect(startInactiveSpanMock).toHaveBeenCalledWith(expect.objectContaining({ name: 'subMiddleware' })); - }); - - it('does not wrap sole route handlers on sub-apps', async () => { - const app = new Hono(); - patchAppUse(app); - patchRoute(app); - - const subApp = new Hono(); - subApp.get('/', () => new Response('sub')); - - app.route('/sub', subApp); - - await app.fetch(new Request('http://localhost/sub')); - - expect(startInactiveSpanMock).not.toHaveBeenCalled(); - }); - - it('does not double-wrap handlers already wrapped by patchAppUse on the main app', async () => { - const app = new Hono(); - patchAppUse(app); - patchRoute(app); - - app.use(async function mainMiddleware(_c: unknown, next: () => Promise) { - await next(); - }); - app.get('/', () => new Response('ok')); - - // Mount the main app as a sub-app of another app (contrived but tests the guard) - const parent = new Hono(); - parent.route('/', app); - - await parent.fetch(new Request('http://localhost/')); - - expect(startInactiveSpanMock).toHaveBeenCalledTimes(1); - expect(startInactiveSpanMock).toHaveBeenCalledWith(expect.objectContaining({ name: 'mainMiddleware' })); - }); - - it('does not patch route() twice when patchRoute is called multiple times', () => { - const app1 = new Hono(); - patchRoute(app1); - - const patchedRoute = honoBaseProto.route; - - const app2 = new Hono(); - patchRoute(app2); - - expect(honoBaseProto.route).toBe(patchedRoute); - }); - - it('stores the original route via __sentry_original__ for other libraries to unwrap', () => { - const app = new Hono(); - patchRoute(app); - - // oxlint-disable-next-line typescript/no-explicit-any - const sentryOriginal = (honoBaseProto.route as any).__sentry_original__; - expect(sentryOriginal).toBe(originalRoute); - }); - - it('wraps path-targeted .use("/path", handler) on sub-apps', async () => { - const app = new Hono(); - patchAppUse(app); - patchRoute(app); - - const subApp = new Hono(); - subApp.use('/admin/*', async function adminAuth(_c: unknown, next: () => Promise) { - await next(); - }); - subApp.get('/admin/dashboard', () => new Response('dashboard')); - - app.route('/api', subApp); - await app.fetch(new Request('http://localhost/api/admin/dashboard')); - - expect(startInactiveSpanMock).toHaveBeenCalledWith(expect.objectContaining({ name: 'adminAuth' })); - }); - - it('does not wrap .all() handlers with less than 2 params (they are route handlers, not middleware)', async () => { - const app = new Hono(); - patchAppUse(app); - patchRoute(app); - - const subApp = new Hono(); - subApp.all('/catch-all', async function allHandler() { - return new Response('catch-all'); - }); - - app.route('/api', subApp); - await app.fetch(new Request('http://localhost/api/catch-all')); - - expect(startInactiveSpanMock).not.toHaveBeenCalled(); - }); - - it('wraps .use() middleware but not .all() handlers on the same sub-app', async () => { - const app = new Hono(); - patchAppUse(app); - patchRoute(app); - - const subApp = new Hono(); - subApp.use(async function mw(_c: unknown, next: () => Promise) { - await next(); - }); - subApp.all('/wildcard', async function allRoute() { - return new Response('wildcard'); - }); - subApp.get('/specific', () => new Response('specific')); - - app.route('/mixed', subApp); - await app.fetch(new Request('http://localhost/mixed/wildcard')); - - const spanNames = startInactiveSpanMock.mock.calls.map((c: unknown[]) => (c[0] as { name: string }).name); - expect(spanNames).toContain('mw'); - expect(spanNames).not.toContain('allRoute'); - }); - - it('does not wrap sole .get()/.post()/.put()/.delete() handlers on sub-apps (they are final handlers, not middleware)', async () => { - const app = new Hono(); - patchAppUse(app); - patchRoute(app); - - const subApp = new Hono(); - subApp.get('/resource', async function getHandler() { - return new Response('get'); - }); - subApp.post('/resource', async function postHandler() { - return new Response('post'); - }); - subApp.put('/resource', async function postHandler() { - return new Response('put'); - }); - subApp.delete('/resource', async function postHandler() { - return new Response('delete'); - }); - - app.route('/api', subApp); - await app.fetch(new Request('http://localhost/api/resource')); - - expect(startInactiveSpanMock).not.toHaveBeenCalled(); - }); - - it('wraps inline middleware in .get(path, mw, handler) on sub-apps', async () => { - const app = new Hono(); - patchAppUse(app); - patchRoute(app); - - const subApp = new Hono(); - subApp.get( - '/resource', - async function inlineMw(_c: unknown, next: () => Promise) { - await next(); - }, - async function getHandler() { - return new Response('get'); - }, - ); - - app.route('/api', subApp); - await app.fetch(new Request('http://localhost/api/resource')); - - const spanNames = startInactiveSpanMock.mock.calls.map((c: unknown[]) => (c[0] as { name: string }).name); - expect(spanNames).toContain('inlineMw'); - expect(spanNames).not.toContain('getHandler'); - }); - - it('wraps separately registered middleware for .get() on sub-apps', async () => { - const app = new Hono(); - patchAppUse(app); - patchRoute(app); - - const subApp = new Hono(); - subApp.get('/resource', async function separateMw(_c: unknown, next: () => Promise) { - await next(); - }); - subApp.get('/resource', async function getHandler() { - return new Response('get'); - }); - - app.route('/api', subApp); - await app.fetch(new Request('http://localhost/api/resource')); - - const spanNames = startInactiveSpanMock.mock.calls.map((c: unknown[]) => (c[0] as { name: string }).name); - expect(spanNames).toContain('separateMw'); - expect(spanNames).not.toContain('getHandler'); - }); - - it('wraps inline middleware registered via .on() on sub-apps', async () => { - const app = new Hono(); - patchAppUse(app); - patchRoute(app); - - const subApp = new Hono(); - subApp.on( - 'GET', - '/resource', - async function onMw(_c: unknown, next: () => Promise) { - await next(); - }, - async function onHandler() { - return new Response('on'); - }, - ); - - app.route('/api', subApp); - await app.fetch(new Request('http://localhost/api/resource')); - - const spanNames = startInactiveSpanMock.mock.calls.map((c: unknown[]) => (c[0] as { name: string }).name); - expect(spanNames).toContain('onMw'); - expect(spanNames).not.toContain('onHandler'); - }); - - it('wraps middleware in nested sub-apps (sub-app mounting another sub-app)', async () => { - const app = new Hono(); - patchAppUse(app); - patchRoute(app); - - const innerSub = new Hono(); - innerSub.use(async function innerMiddleware(_c: unknown, next: () => Promise) { - await next(); - }); - innerSub.get('/', () => new Response('inner')); - - const outerSub = new Hono(); - outerSub.use(async function outerMiddleware(_c: unknown, next: () => Promise) { - await next(); - }); - outerSub.route('/inner', innerSub); - - app.route('/outer', outerSub); - await app.fetch(new Request('http://localhost/outer/inner')); - - const spanNames = startInactiveSpanMock.mock.calls.map((c: unknown[]) => (c[0] as { name: string }).name); - expect(spanNames).toContain('outerMiddleware'); - expect(spanNames).toContain('innerMiddleware'); - }); - - it('handles sub-app with multiple path-targeted middleware for different paths', async () => { - const app = new Hono(); - patchAppUse(app); - patchRoute(app); - - const subApp = new Hono(); - subApp.use('/a/*', async function mwForA(_c: unknown, next: () => Promise) { - await next(); - }); - subApp.use('/b/*', async function mwForB(_c: unknown, next: () => Promise) { - await next(); - }); - subApp.get('/a/test', () => new Response('a')); - subApp.get('/b/test', () => new Response('b')); - - app.route('/sub', subApp); - - // Hit path /a — only mwForA should fire - await app.fetch(new Request('http://localhost/sub/a/test')); - expect(startInactiveSpanMock).toHaveBeenCalledTimes(1); - expect(startInactiveSpanMock).toHaveBeenCalledWith(expect.objectContaining({ name: 'mwForA' })); - - startInactiveSpanMock.mockClear(); - - // Hit path /b — only mwForB should fire - await app.fetch(new Request('http://localhost/sub/b/test')); - expect(startInactiveSpanMock).toHaveBeenCalledTimes(1); - expect(startInactiveSpanMock).toHaveBeenCalledWith(expect.objectContaining({ name: 'mwForB' })); - }); - }); }); diff --git a/packages/hono/test/shared/patchRoute.test.ts b/packages/hono/test/shared/patchRoute.test.ts deleted file mode 100644 index d9dd4d6795ad..000000000000 --- a/packages/hono/test/shared/patchRoute.test.ts +++ /dev/null @@ -1,141 +0,0 @@ -import * as SentryCore from '@sentry/core'; -import { Hono } from 'hono'; -import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'; -import { patchRoute } from '../../src/shared/patchRoute'; - -vi.mock('@sentry/core', async () => { - const actual = await vi.importActual('@sentry/core'); - return { - ...actual, - startInactiveSpan: vi.fn((_opts: unknown) => ({ - setStatus: vi.fn(), - end: vi.fn(), - })), - }; -}); - -const startInactiveSpanMock = SentryCore.startInactiveSpan as ReturnType; - -const honoBaseProto = Object.getPrototypeOf(Object.getPrototypeOf(new Hono())); -const originalRoute = honoBaseProto.route; - -describe('patchRoute', () => { - beforeEach(() => { - vi.clearAllMocks(); - honoBaseProto.route = originalRoute; - }); - - afterAll(() => { - honoBaseProto.route = originalRoute; - }); - - it('is a no-op when honoBaseProto.route is not a function', () => { - const fakeApp = Object.create({ notRoute: () => {} }) as Hono; - // Should not throw even when the expected method shape is missing - expect(() => patchRoute(fakeApp)).not.toThrow(); - expect(honoBaseProto.route).toBe(originalRoute); - }); - - describe('wrapSubAppMiddleware', () => { - it('does nothing when a sub-app has an empty routes array', async () => { - const app = new Hono(); - patchRoute(app); - - const emptySubApp = new Hono(); - // routes is an empty array — nothing to wrap, nothing should throw - app.route('/empty', emptySubApp); - - const res = await app.fetch(new Request('http://localhost/empty')); - expect(res.status).toBe(404); - expect(startInactiveSpanMock).not.toHaveBeenCalled(); - }); - - it('skips route entries whose handler is not a function', async () => { - const app = new Hono(); - patchRoute(app); - - const subApp = new Hono(); - subApp.get('/resource', () => new Response('ok')); - - // Corrupt one handler to a non-function to simulate unexpected route shapes - (subApp.routes as unknown as Array<{ handler: unknown }>)[0]!.handler = 'not-a-function'; - - // Should not throw when iterating over the corrupted routes - expect(() => app.route('/api', subApp)).not.toThrow(); - expect(startInactiveSpanMock).not.toHaveBeenCalled(); - }); - - it('treats same path with different HTTP methods as separate groups', async () => { - const app = new Hono(); - patchRoute(app); - - const subApp = new Hono(); - // Each of these is the sole (last) handler for its method+path group, - // so none should be wrapped as middleware. - subApp.get('/resource', async function getHandler() { - return new Response('get'); - }); - subApp.post('/resource', async function postHandler() { - return new Response('post'); - }); - - app.route('/api', subApp); - - await app.fetch(new Request('http://localhost/api/resource', { method: 'GET' })); - await app.fetch(new Request('http://localhost/api/resource', { method: 'POST' })); - - expect(startInactiveSpanMock).not.toHaveBeenCalled(); - }); - - it('treats same HTTP method with different paths as separate groups', async () => { - const app = new Hono(); - patchRoute(app); - - const subApp = new Hono(); - // Each is the sole handler for its own method+path group — neither is middleware. - subApp.get('/alpha', async function alphaHandler() { - return new Response('alpha'); - }); - subApp.get('/beta', async function betaHandler() { - return new Response('beta'); - }); - - app.route('/api', subApp); - - await app.fetch(new Request('http://localhost/api/alpha')); - await app.fetch(new Request('http://localhost/api/beta')); - - expect(startInactiveSpanMock).not.toHaveBeenCalled(); - }); - - it('wraps inline middleware for GET /alpha but not the sole handler for GET /beta', async () => { - const app = new Hono(); - patchRoute(app); - - const subApp = new Hono(); - subApp.get( - '/alpha', - async function alphaMw(_c: unknown, next: () => Promise) { - await next(); - }, - async function alphaHandler() { - return new Response('alpha'); - }, - ); - subApp.get('/beta', async function betaHandler() { - return new Response('beta'); - }); - - app.route('/api', subApp); - - await app.fetch(new Request('http://localhost/api/alpha')); - await app.fetch(new Request('http://localhost/api/beta')); - - const spanNames = startInactiveSpanMock.mock.calls.map((c: unknown[]) => (c[0] as { name: string }).name); - expect(spanNames).toHaveLength(1); - expect(spanNames).toContain('alphaMw'); - expect(spanNames).not.toContain('alphaHandler'); - expect(spanNames).not.toContain('betaHandler'); - }); - }); -});