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
52 changes: 52 additions & 0 deletions .changeset/endpoint-mount-fallback-seam.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
---
"@objectstack/plugin-hono-server": minor
"@objectstack/runtime": minor
---

feat(runtime,hono): 挂载 seam —— `setFallbackHandler` 实现 + 声明式端点派发步(#5040 E3, #5090)

给声明式 `apis:` 端点铺上**唯一一条**能进入处理器的通路,并且这条通路在构造上不可能遮蔽任何
已注册路由。执行器本身尚未落地,本次改动**零现网行为变更**:任何 stack 目前都无法发布非空
`apis:`(publish 硬拒,直到 #5040 E7 翻转),所以这里新增的一切在真实组合里结构性不可达。

**`@objectstack/plugin-hono-server` —— `IHttpServer.setFallbackHandler` 的实现**

契约(#5080 落在 `@objectstack/spec/contracts`)的四条保证逐条兑现:

- 映射到 Hono 的 `app.notFound` 钩子,**不是**通配路由。这是全部要点:通配路由要与之后注册
的每一条路由竞争,而 Hono 按先注册者赢裁决,归属就变成插件 `start()` 顺序的函数 ——
ADR-0076 D11 正是为此存在。兜底器只在全部显式路由未命中后运行,**零注册顺序依赖**。
- handler 拿到的 `req.body` **可读**(与 `use()` 中间件 seam 相反,后者的契约明确不填充
body),按 content-type 解析,与真实路由处理器走同一段代码。
- 重复安装即**替换**,不成链。
- handler 不写响应 → 适配器既有的未命中答案(404,或方法不匹配时 405 + `Allow`)原样保留。

配套的一处属主收敛:404/405 应答此前由 `HonoServerPlugin.start()` 直接写在
`getRawApp().notFound(...)` 上。`app.notFound` 是后调用者覆盖,兜底 seam 落在同一个钩子上,
两个写入方意味着幸存者由插件启动顺序决定 —— 应答本体因此移入 `HonoHttpServer`
(`installNotFoundSeam()` / `setFallbackHandler()` 在其中组合),一个钩子一个属主。行为
逐字节不变(`notfound-405.test.ts` 原样通过)。

顺带修好同一段代码上的两处不一致:适配器构造的 `IHttpRequest` 现在一律带
`remoteAddress`(此前只有中间件 seam 有,同一个契约有两种形状);处理器**同步**抛出与
异步 reject 现在报同一种结果(此前同步抛出会逃到 Hono 自己的错误页)。

**`@objectstack/runtime` —— dispatcher 端点派发步**

dispatcher-plugin 在 `start()` 中探测 `typeof server.setFallbackHandler === 'function'`
并注册兜底器。对落在 ADR-0121 D1 保留段 `<prefix>/apps/<命名空间>/<子路径>` 下的请求,
探测 `metadata` 服务的 `matchEndpoint`(#5089 的实现在并行开发,探测缺席即穿透):

- **命中** → `501 NOT_IMPLEMENTED`,包络说明执行器随 17.x 落地(#5040 E4–E5 接策略键与
执行目标);
- **未命中 / 无 matcher / 无 metadata 服务 / 路径不在挂载前缀下** → **不写任何响应**,
传输层既有的 404/405 答案原样成立(有回归测试逐字节钉住);
- `matchEndpoint` 抛错按 5xx 出口应答,不降级为 404 —— 故障不得伪装成「没有这条路由」。

派发步**不重入** `dispatch()`:那条管线会解析环境与 `executionContext`、跑匿名拒绝门、并以
语义 404 收尾,把全部未命中请求灌进去会改变今天未命中请求的答案。裸 404 与语义 404 的收口
是另一个决定,本次刻意不做。

`route-ledger.ts` 新增 `* /apps/**` 登记行与 `NON_DISPATCH_MOUNT_PREFIXES`(本包在
`dispatch()` 之外挂载的前缀),注记如实描述已接线的部分与**尚未**接线的执行部分;新增
一致性测试钉住 ADR-0121 D1 赖以成立的事实 —— `/apps` 不属于任何内建域。
466 changes: 331 additions & 135 deletions packages/plugins/plugin-hono-server/src/adapter.ts

Large diffs are not rendered by default.

235 changes: 235 additions & 0 deletions packages/plugins/plugin-hono-server/src/fallback-seam.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* `HonoHttpServer.setFallbackHandler()` — the unmatched-request seam, exercised
* through the real Hono app (`app.fetch`), never a mock.
*
* ## Why this file exists (#5090, #5040 E3)
*
* The contract on `IHttpServer.setFallbackHandler` makes four promises, and
* each one is a thing an implementation could plausibly get wrong:
*
* 1. it runs ONLY after every registered route missed — the reason this is a
* `notFound` hook and not a `${prefix}/*` wildcard route, which Hono would
* resolve by first-registration-wins across plugin `start()` order
* (ADR-0076 D11);
* 2. `req.body` IS readable here, unlike the `use()` middleware seam whose
* contract explicitly does not populate it;
* 3. installing again REPLACES — one fallback, never a chain;
* 4. a handler that writes NOTHING leaves the adapter's standard answer in
* place — 404, or 405 + `Allow` for a method mismatch.
*
* (4) is also the design-flagged risk (#5040 §7-1) made concrete: Hono routes a
* method mismatch to the SAME `notFound` sink as a missing path, so the
* fallback sees those requests too and must be able to decline them without
* costing the 405. `notfound-405.test.ts` pins the no-fallback baseline; this
* file pins it with a fallback installed.
*/

import { describe, it, expect } from 'vitest';
import type { IHttpRequest, IHttpResponse } from '@objectstack/core';

import { HonoHttpServer } from './adapter';

/** A server with routes + the standard unmatched-request seam mounted. */
function serverWithRoutes() {
const server = new HonoHttpServer(0);
server.get('/api/v1/thing', (_req, res) => { res.status(200); res.json({ route: 'get' }); });
server.post('/api/v1/thing', (_req, res) => { res.status(201); res.json({ route: 'post' }); });
server.put('/api/v1/only-put', (_req, res) => { res.status(200); res.json({ route: 'put' }); });
server.installNotFoundSeam();
return server;
}

const call = (server: HonoHttpServer, path: string, init?: RequestInit) =>
server.getRawApp().fetch(new Request(`http://localhost${path}`, init));

const postJson = (server: HonoHttpServer, path: string, body: unknown) =>
call(server, path, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
});

describe('guarantee 1 — a fallback can never shadow a registered route', () => {
it('does not run for a path+method a route owns', async () => {
const server = serverWithRoutes();
let ran = false;
server.setFallbackHandler((_req, res) => { ran = true; res.status(200); res.json({ from: 'fallback' }); });

const res = await call(server, '/api/v1/thing');
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ route: 'get' });
expect(ran, 'fallback ran for a request a registered route matched').toBe(false);
});

it('is order-independent — installing BEFORE the routes changes nothing', async () => {
// The whole point of mapping onto `notFound` instead of a wildcard
// route: with a `${prefix}/*` route this test is the one that fails.
const server = new HonoHttpServer(0);
server.setFallbackHandler((_req, res) => { res.status(200); res.json({ from: 'fallback' }); });
server.get('/api/v1/thing', (_req, res) => { res.status(200); res.json({ route: 'get' }); });

expect(await (await call(server, '/api/v1/thing')).json()).toEqual({ route: 'get' });
expect(await (await call(server, '/api/v1/other')).json()).toEqual({ from: 'fallback' });
});

it('runs for a path no route owns', async () => {
const server = serverWithRoutes();
const seen: Array<{ method: string; path: string }> = [];
server.setFallbackHandler((req, res) => {
seen.push({ method: req.method, path: req.path });
res.status(200);
res.json({ from: 'fallback' });
});

const res = await call(server, '/api/v1/apps/showcase/tasks');
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ from: 'fallback' });
expect(seen).toEqual([{ method: 'GET', path: '/api/v1/apps/showcase/tasks' }]);
});
});

describe('guarantee 2 — the fallback receives a fully populated request', () => {
it('reads a JSON body (the difference from the `use()` middleware seam)', async () => {
const server = serverWithRoutes();
let received: IHttpRequest | undefined;
server.setFallbackHandler((req, res) => { received = req; res.status(200); res.json({ ok: true }); });

await postJson(server, '/api/v1/apps/showcase/inquiries/purge?dry=1', { reason: 'stale', count: 3 });

expect(received?.body).toEqual({ reason: 'stale', count: 3 });
expect(received?.query).toEqual({ dry: '1' });
expect(received?.method).toBe('POST');
expect(received?.path).toBe('/api/v1/apps/showcase/inquiries/purge');
expect(received?.headers['content-type']).toContain('application/json');
// Backfilled from the URL — Fetch `Request` hides the Host header, and
// hostname-based environment routing depends on it.
expect(received?.headers.host).toBe('localhost');
expect(typeof received?.rawBody).toBe('function');
});

it('parses a form body by content-type, exactly as a route handler would', async () => {
const server = serverWithRoutes();
let routeBody: unknown;
let fallbackBody: unknown;
server.post('/api/v1/echo', (req, res) => { routeBody = req.body; res.status(200); res.json({ ok: true }); });
server.setFallbackHandler((req, res) => { fallbackBody = req.body; res.status(200); res.json({ ok: true }); });

const form = () => {
const body = new URLSearchParams({ a: '1', b: 'two' });
return { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body };
};
await call(server, '/api/v1/echo', form() as RequestInit);
await call(server, '/api/v1/apps/showcase/form', form() as RequestInit);

expect(fallbackBody).toEqual({ a: '1', b: 'two' });
expect(fallbackBody).toEqual(routeBody);
});

it('leaves `body` an empty object when there is none', async () => {
const server = serverWithRoutes();
let received: IHttpRequest | undefined;
server.setFallbackHandler((req, res) => { received = req; res.status(200); res.json({ ok: true }); });

await call(server, '/api/v1/apps/showcase/tasks');
expect(received?.body).toEqual({});
});
});

describe('guarantee 3 — installing again REPLACES', () => {
it('runs only the most recently installed handler', async () => {
const server = serverWithRoutes();
const ran: string[] = [];
server.setFallbackHandler((_req, res) => { ran.push('first'); res.status(200); res.json({ which: 'first' }); });
server.setFallbackHandler((_req, res) => { ran.push('second'); res.status(200); res.json({ which: 'second' }); });

const res = await call(server, '/api/v1/apps/showcase/tasks');
expect(await res.json()).toEqual({ which: 'second' });
expect(ran).toEqual(['second']);
});

it('does not stack the `notFound` hook when installed repeatedly', async () => {
const server = serverWithRoutes();
let calls = 0;
for (let i = 0; i < 3; i++) {
server.setFallbackHandler((_req, res) => { calls++; res.status(200); res.json({ i }); });
}
await call(server, '/api/v1/apps/showcase/tasks');
expect(calls).toBe(1);
});
});

describe('guarantee 4 — a fallback that writes nothing leaves the standard answer', () => {
it('keeps the 404 body byte-for-byte', async () => {
const withoutFallback = serverWithRoutes();
const baseline = await call(withoutFallback, '/api/v1/apps/showcase/tasks');

const server = serverWithRoutes();
let ran = false;
server.setFallbackHandler(() => { ran = true; /* declines */ });
const res = await call(server, '/api/v1/apps/showcase/tasks');

expect(ran).toBe(true);
expect(res.status).toBe(baseline.status);
expect(res.status).toBe(404);
expect(await res.text()).toBe(await baseline.text());
expect(JSON.parse(await (await call(serverWithRoutes(), '/api/v1/nope')).text()))
.toEqual({ error: 'Not found' });
});

it('keeps the 405 + `Allow` answer for a method mismatch (#5040 §7-1)', async () => {
// Hono sends a method mismatch to the SAME notFound sink, so the
// fallback is consulted for these too — and declining must not cost the
// 405 that `notfound-405.test.ts` pins without a fallback installed.
const server = serverWithRoutes();
const seen: string[] = [];
server.setFallbackHandler((req) => { seen.push(`${req.method} ${req.path}`); });

const res = await call(server, '/api/v1/only-put', { method: 'DELETE' });
expect(res.status).toBe(405);
expect(res.headers.get('Allow')).toBe('PUT');
const body = await res.json();
expect(body.code).toBe('METHOD_NOT_ALLOWED');
expect(body.allowed).toEqual(['PUT']);
expect(seen).toEqual(['DELETE /api/v1/only-put']);
});

it('answers 405 rather than the fallback even when the fallback WOULD answer', async () => {
// Declining is the fallback's own choice; a fallback that answers a
// method mismatch is allowed to (it saw the request first, by contract).
// Pinned so the precedence is a decision on record, not an accident.
const server = serverWithRoutes();
server.setFallbackHandler((req, res) => {
if (req.path.startsWith('/api/v1/apps/')) { res.status(200); res.json({ from: 'fallback' }); }
});
const res = await call(server, '/api/v1/only-put', { method: 'DELETE' });
expect(res.status).toBe(405);
});
});

describe('failure modes', () => {
it('reports a THROWING fallback as a 500 instead of hiding it in the 404', async () => {
const server = serverWithRoutes();
server.setFallbackHandler(() => { throw new Error('boom'); });
const res = await call(server, '/api/v1/apps/showcase/tasks');
expect(res.status).toBe(500);
expect(await res.json()).toEqual({ error: 'Fallback handler failed' });
});

it('honours a status set without a body (the `res.end()` shape)', async () => {
const server = serverWithRoutes();
server.setFallbackHandler((_req, res: IHttpResponse) => { res.status(204); res.end?.(); });
const res = await call(server, '/api/v1/apps/showcase/tasks');
expect(res.status).toBe(204);
});

it('a bare server without the seam keeps Hono\'s own 404 (no silent behavior change)', async () => {
const server = new HonoHttpServer(0);
server.get('/api/v1/thing', (_req, res) => { res.json({ ok: true }); });
const res = await server.getRawApp().fetch(new Request('http://localhost/api/v1/nope'));
expect(res.status).toBe(404);
// Hono's built-in answer — NOT this adapter's `{ error: 'Not found' }`.
expect(await res.text()).toBe('404 Not Found');
});
});
6 changes: 6 additions & 0 deletions packages/plugins/plugin-hono-server/src/hono-plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ vi.mock('./adapter', async (importOriginal) => ({
// end of init(). Real behaviour is covered against the REAL adapter
// in `middleware-seam.test.ts`; here it only has to exist.
installMiddlewareSeam: vi.fn(),
// [#5090] Same deal for the unmatched-request seam: `start()` mounts
// it through the adapter now (one owner for `app.notFound`, which is
// last-call-wins). The real 404/405/fallback composition is covered
// against the REAL adapter in `notfound-405.test.ts` and
// `fallback-seam.test.ts`; here it only has to exist.
installNotFoundSeam: vi.fn(),
getRawApp: vi.fn().mockReturnValue({
get: vi.fn(),
use: vi.fn(),
Expand Down
42 changes: 13 additions & 29 deletions packages/plugins/plugin-hono-server/src/hono-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -522,37 +522,21 @@ export class HonoServerPlugin implements Plugin {
}
}

// ─── Unmatched-request seam ───────────────────────────────────────────
// Catch-all: ensure unmatched requests always get a proper Response
// (prevents Hono "Context is not finalized" error).
// (prevents Hono "Context is not finalized" error), and answer a method
// mismatch with 405 + `Allow` rather than an opaque 404 (#2684).
//
// 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) => {
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);
});
}
// The answer itself now lives in `HonoHttpServer.unmatchedResponse` and
// this call only MOUNTS the hook (#5090). It moved because `notFound` is
// last-call-wins and gained a second writer: `setFallbackHandler` — the
// `IHttpServer` seam the declarative-endpoint dispatcher installs — maps
// onto the same hook. Two writers of one hook would have meant the
// survivor was decided by plugin start order, silently costing whichever
// lost. One hook, one owner: the adapter composes fallback-then-standard
// answer inside it, so this call and any `setFallbackHandler()` are
// order-independent.
this.server.installNotFoundSeam();

// Register endpoints during kernel:ready so they're wired up alongside
// other plugins' route registrations.
Expand Down
Loading
Loading