Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/hono-server-405-method-not-allowed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'@objectstack/plugin-hono-server': patch
---

Return `405 Method Not Allowed` (with an accurate `Allow` header and a
descriptive body) instead of an opaque `{"error":"Not found"}` 404 when a
request hits a registered path under the wrong HTTP method.

Hono routes a method mismatch to the same `notFound` sink as a genuinely
missing path, so a `POST` to a `PUT`-only route (e.g. the metadata save
endpoint `PUT /api/v1/meta/:type/:name`) gave callers no hint that the path
exists under another verb (#2684). The server now tracks every registered
`(method, pattern)` pair and re-matches the request path in the `notFound`
handler: matching another method yields a 405; matching nothing stays a 404.
This is framework-wide — every registered endpoint benefits. Static/SPA
catch-alls registered straight on the raw Hono app are not tracked and never
produce a spurious 405.
30 changes: 30 additions & 0 deletions packages/plugins/plugin-hono-server/src/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { currentPerfTiming } from '@objectstack/observability';
import { Hono } from 'hono';
import { serve } from '@hono/node-server';
import { serveStatic } from '@hono/node-server/serve-static';
import { matchesRoutePattern } from './route-pattern';

export interface HonoCorsOptions {
enabled?: boolean;
Expand Down Expand Up @@ -44,6 +45,14 @@ export class HonoHttpServer implements IHttpServer {
private app: Hono;
private server: any;
private listeningPort: number | undefined;
/**
* Every `(method, pattern)` pair registered through this server, kept so
* the `notFound` handler can answer "the path exists but the method is
* wrong" with a `405` + `Allow` instead of an opaque `404`. Populated by
* the verb methods below; static/SPA catch-alls registered straight on the
* raw Hono app are intentionally NOT tracked, so they never produce a 405.
*/
private registeredRoutes: Array<{ method: string; pattern: string }> = [];

constructor(
private port: number = 3000,
Expand Down Expand Up @@ -210,21 +219,42 @@ export class HonoHttpServer implements IHttpServer {
}

get(path: string, handler: RouteHandler) {
this.registeredRoutes.push({ method: 'GET', pattern: path });
this.app.get(path, this.wrap(handler));
}
post(path: string, handler: RouteHandler) {
this.registeredRoutes.push({ method: 'POST', pattern: path });
this.app.post(path, this.wrap(handler));
}
put(path: string, handler: RouteHandler) {
this.registeredRoutes.push({ method: 'PUT', pattern: path });
this.app.put(path, this.wrap(handler));
}
delete(path: string, handler: RouteHandler) {
this.registeredRoutes.push({ method: 'DELETE', pattern: path });
this.app.delete(path, this.wrap(handler));
}
patch(path: string, handler: RouteHandler) {
this.registeredRoutes.push({ method: 'PATCH', pattern: path });
this.app.patch(path, this.wrap(handler));
}

/**
* The HTTP methods registered for a concrete request `path`, ignoring the
* request's own method. Empty when no registered route matches the path at
* all (a genuine 404). Used by the `notFound` handler to build a `405`
* response with an accurate `Allow` header. `HEAD` is implied by `GET`
* (Hono answers HEAD from GET routes automatically).
*/
allowedMethodsForPath(path: string): string[] {
const methods = new Set<string>();
for (const route of this.registeredRoutes) {
if (matchesRoutePattern(route.pattern, path)) methods.add(route.method);
}
if (methods.has('GET')) methods.add('HEAD');
return Array.from(methods).sort();
}

use(pathOrHandler: string | Middleware, handler?: Middleware) {
if (typeof pathOrHandler === 'string' && handler) {
this.app.use(pathOrHandler, async (c, next) => {
Expand Down
29 changes: 27 additions & 2 deletions packages/plugins/plugin-hono-server/src/hono-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -344,10 +344,35 @@ export class HonoServerPlugin implements Plugin {
}

// Catch-all: ensure unmatched requests always get a proper Response
// (prevents Hono "Context is not finalized" error)
// (prevents Hono "Context is not finalized" error).
//
// Hono routes a method mismatch to the SAME `notFound` sink as a
// genuinely missing path, so a `POST` to a `PUT`-only route (e.g. the
// metadata save endpoint, see #2684) used to return an opaque
// `{ error: 'Not found' }` 404 with no hint that the path exists under
// another verb. Here we re-match the request path against the set of
// registered route patterns: if it lines up with routes under other
// methods, answer `405 Method Not Allowed` with an accurate `Allow`
// header so callers can self-correct. A path that matches nothing
// stays a 404. This is framework-wide — every registered endpoint
// benefits, not just metadata.
const rawAppForNotFound = this.server.getRawApp();
if (typeof rawAppForNotFound.notFound === 'function') {
rawAppForNotFound.notFound((c: any) => c.json({ error: 'Not found' }, 404));
rawAppForNotFound.notFound((c: any) => {
const allowed = this.server.allowedMethodsForPath(c.req.path);
if (allowed.length > 0 && !allowed.includes(c.req.method)) {
c.header('Allow', allowed.join(', '));
return c.json({
error: 'Method Not Allowed',
code: 'METHOD_NOT_ALLOWED',
message: `${c.req.method} is not supported for ${c.req.path}. Allowed: ${allowed.join(', ')}.`,
method: c.req.method,
path: c.req.path,
allowed,
}, 405);
}
return c.json({ error: 'Not found' }, 404);
});
}

// Register standard endpoints during kernel:ready so they're
Expand Down
1 change: 1 addition & 0 deletions packages/plugins/plugin-hono-server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@
export * from './hono-plugin';
export * from './adapter';
export * from './pattern-matcher';
export * from './route-pattern';

90 changes: 90 additions & 0 deletions packages/plugins/plugin-hono-server/src/notfound-405.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { HonoServerPlugin } from './hono-plugin';
import { HonoHttpServer } from './adapter';
import type { PluginContext } from '@objectstack/core';

// NB: this file deliberately does NOT mock './adapter' — it exercises the real
// HonoHttpServer + the plugin's notFound wiring end-to-end through app.fetch().

function makeCtx() {
const logger = { info: vi.fn(), debug: vi.fn(), warn: vi.fn(), error: vi.fn() };
return {
logger,
getKernel: vi.fn().mockReturnValue({ plugins: new Map() }),
registerService: vi.fn(),
hook: vi.fn(),
getService: vi.fn(),
} as unknown as PluginContext;
}

describe('HonoHttpServer.allowedMethodsForPath', () => {
let server: HonoHttpServer;
const noop = () => {};

beforeEach(() => {
server = new HonoHttpServer(0);
server.get('/api/v1/meta/:type/:name', noop);
server.put('/api/v1/meta/:type/:name', noop);
server.delete('/api/v1/meta/:type/:name', noop);
server.post('/api/v1/meta/:type/:name/publish', noop);
});

it('collects every method registered for a concrete path (HEAD implied by GET)', () => {
expect(server.allowedMethodsForPath('/api/v1/meta/view/my_view'))
.toEqual(['DELETE', 'GET', 'HEAD', 'PUT']);
});

it('does not leak sub-route methods onto the parent path', () => {
// /publish is POST-only and one segment deeper — must not appear here
expect(server.allowedMethodsForPath('/api/v1/meta/view/my_view'))
.not.toContain('POST');
expect(server.allowedMethodsForPath('/api/v1/meta/view/my_view/publish'))
.toEqual(['POST']);
});

it('returns an empty list for a path that matches no route', () => {
expect(server.allowedMethodsForPath('/api/v1/nope')).toEqual([]);
});
});

describe('notFound → 405 Method Not Allowed (#2684)', () => {
let plugin: HonoServerPlugin;
let server: HonoHttpServer;

beforeEach(async () => {
const ctx = makeCtx();
plugin = new HonoServerPlugin({ cors: false });
await plugin.init(ctx);
await plugin.start(ctx);
server = (plugin as any).server as HonoHttpServer;
// Register the metadata save surface the same way rest-server does.
server.get('/api/v1/meta/:type/:name', (_req: any, res: any) => res.json({ ok: 'get' }));
server.put('/api/v1/meta/:type/:name', (_req: any, res: any) => res.json({ ok: 'put' }));
server.delete('/api/v1/meta/:type/:name', (_req: any, res: any) => res.json({ ok: 'delete' }));
});

const fetch = (method: string, path: string) =>
server.getRawApp().fetch(new Request(`http://localhost${path}`, { method }));

it('answers a POST to a PUT-only path with 405 + Allow header', async () => {
const res = await fetch('POST', '/api/v1/meta/view/my_view');
expect(res.status).toBe(405);
const allow = (res.headers.get('Allow') || '').split(',').map((s) => s.trim());
expect(allow).toEqual(expect.arrayContaining(['GET', 'PUT', 'DELETE']));
const body = await res.json();
expect(body.code).toBe('METHOD_NOT_ALLOWED');
expect(body.allowed).toEqual(expect.arrayContaining(['PUT']));
});

it('still routes the correct method to its handler (no false 405)', async () => {
const res = await fetch('PUT', '/api/v1/meta/view/my_view');
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ ok: 'put' });
});

it('keeps a genuine 404 for a path that matches no registered route', async () => {
const res = await fetch('POST', '/api/v1/does/not/exist');
expect(res.status).toBe(404);
expect(await res.json()).toEqual({ error: 'Not found' });
});
});
41 changes: 41 additions & 0 deletions packages/plugins/plugin-hono-server/src/route-pattern.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { describe, it, expect } from 'vitest';
import { compileRoutePattern, matchesRoutePattern } from './route-pattern';

describe('compileRoutePattern', () => {
it('compiles a literal path to an anchored regex', () => {
const re = compileRoutePattern('/api/v1/meta');
expect(re.test('/api/v1/meta')).toBe(true);
expect(re.test('/api/v1/meta/view')).toBe(false);
expect(re.test('/api/v1/metadata')).toBe(false);
});

it('matches a named param as a single segment', () => {
const re = compileRoutePattern('/api/v1/meta/:type/:name');
expect(re.test('/api/v1/meta/view/my_view')).toBe(true);
// :name must not swallow an extra segment (that is /publish territory)
expect(re.test('/api/v1/meta/view/my_view/publish')).toBe(false);
});

it('expands a trailing wildcard across segments', () => {
const re = compileRoutePattern('/api/v1/auth/*');
expect(re.test('/api/v1/auth/session')).toBe(true);
expect(re.test('/api/v1/auth/oauth/callback/google')).toBe(true);
});
});

describe('matchesRoutePattern', () => {
it('ignores a single trailing slash on either side', () => {
expect(matchesRoutePattern('/api/v1/meta/:type', '/api/v1/meta/view/')).toBe(true);
expect(matchesRoutePattern('/api/v1/meta/:type/', '/api/v1/meta/view')).toBe(true);
});

it('does not match when a param segment is empty', () => {
// POST /meta/view/?mode=draft — the :name segment is missing entirely
expect(matchesRoutePattern('/api/v1/meta/:type/:name', '/api/v1/meta/view/')).toBe(false);
});

it('treats regex metacharacters in the pattern as literals', () => {
expect(matchesRoutePattern('/api/v1/a.b', '/api/v1/a.b')).toBe(true);
expect(matchesRoutePattern('/api/v1/a.b', '/api/v1/axb')).toBe(false);
});
});
45 changes: 45 additions & 0 deletions packages/plugins/plugin-hono-server/src/route-pattern.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Route-path pattern matching, used to answer "does this concrete request
* path correspond to a registered route pattern?" independent of HTTP method.
*
* This exists to support a proper `405 Method Not Allowed` response: Hono's
* router treats a method mismatch the same as a missing route (both fall
* through to `notFound`), so to distinguish "the path exists but the method is
* wrong" from "the path doesn't exist at all" we re-match the request path
* against the set of registered patterns ourselves.
*
* Supports the subset of Hono / Express path syntax the framework registers:
* - `:param` → a single non-empty path segment (`[^/]+`)
* - `*` → any remaining characters, including `/` (`.*`)
* Everything else is treated as a literal and regex-escaped.
*/

/** Strip a single trailing slash so `/a/b` and `/a/b/` match the same pattern. */
function normalize(path: string): string {
if (path.length > 1 && path.endsWith('/')) return path.slice(0, -1);
return path;
}

/**
* Compile a Hono-style route pattern into an anchored `RegExp` that matches a
* concrete request path. Named params match one segment; `*` matches the rest.
*/
export function compileRoutePattern(pattern: string): RegExp {
const regexBody = normalize(pattern)
.split('/')
.map((segment) => {
if (segment.startsWith(':')) return '[^/]+';
if (segment === '*') return '.*';
// Escape regex metacharacters, then expand any inline `*` wildcard.
return segment.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*');
})
.join('/');
return new RegExp(`^${regexBody}$`);
}

/** True when `path` is matched by the compiled route `pattern`. */
export function matchesRoutePattern(pattern: string, path: string): boolean {
return compileRoutePattern(pattern).test(normalize(path));
}