diff --git a/.changeset/router-root-mount.md b/.changeset/router-root-mount.md new file mode 100644 index 0000000..c5bd9a6 --- /dev/null +++ b/.changeset/router-root-mount.md @@ -0,0 +1,5 @@ +--- +"@chttp/router": patch +--- + +fix(router): `route("/", sub)` now mounts the sub-app at the root instead of registering double-slash paths (`//health`) that 404 on every request. A `"/"` prefix collapses to `""` before `mountRoutes` concatenation, matching Hono behavior. diff --git a/packages/router/src/index.ts b/packages/router/src/index.ts index 79c0b58..22dbc56 100644 --- a/packages/router/src/index.ts +++ b/packages/router/src/index.ts @@ -1698,7 +1698,13 @@ export class App { route(prefix: string, sub: App): App { let p = String(prefix); - if (p.length > 1 && p.charAt(p.length - 1) === "/") { + // A "/" prefix mounts at the root (issue #97): mountRoutes concatenates + // prefix + sub-path verbatim, so "/" must collapse to "" instead of + // registering "//health"-style double-slash paths. Its empty-path guard + // then turns "" + "" into "/" for root handlers. + if (p === "/") { + p = ""; + } else if (p.length > 1 && p.charAt(p.length - 1) === "/") { p = String(p.slice(0, p.length - 1)); } mountRoutes(sub, this, p, copyMw(this._mw)); diff --git a/packages/router/test/router.test.js b/packages/router/test/router.test.js index 497ca3b..d9f8d4f 100644 --- a/packages/router/test/router.test.js +++ b/packages/router/test/router.test.js @@ -313,6 +313,44 @@ test("route() mounts a sub-app under a prefix preserving params", () => { assert.equal(call(app, { url: "/api/" }).body, "subroot"); }); +test('route("/", sub) mounts sub-app routes at the root (issue #97)', () => { + const sub = createRouter(); + sub.get("/health", (c) => c.text("ok")); + const app = createRouter(); + app.route("/", sub); + const res = call(app, { url: "/health" }); + assert.equal(res.statusCode, 200); + assert.equal(res.body, "ok"); +}); + +test('route("/", sub) resolves a handler registered at "/" in the sub-app', () => { + const sub = createRouter(); + sub.get("/", (c) => c.text("subroot")); + const app = createRouter(); + app.route("/", sub); + const res = call(app, { url: "/" }); + assert.equal(res.statusCode, 200); + assert.equal(res.body, "subroot"); +}); + +test('route("/", sub) preserves params through the root mount', () => { + const sub = createRouter(); + sub.get("/item/:id", (c) => c.json({ id: c.req.param("id") })); + const app = createRouter(); + app.route("/", sub); + assert.equal(JSON.parse(call(app, { url: "/item/7" }).body).id, "7"); + assert.equal(call(app, { url: "/missing" }).statusCode, 404); +}); + +test('route("/api", sub) still mounts under the prefix after the root fix', () => { + const sub = createRouter(); + sub.get("/users", (c) => c.text("users")); + const app = createRouter(); + app.route("/api", sub); + assert.equal(call(app, { url: "/api/users" }).body, "users"); + assert.equal(call(app, { url: "/users" }).statusCode, 404); +}); + test("route() inherits middleware from both sides", () => { const hits = []; const sub = createRouter();