feat(http): add route(path) proxy for sub-path scoped registration

RouteProxy<T> wraps a Router and prepends a fixed prefix to every
path-taking method (get, post, put, patch, delete, options, use, layer,
route). Routes and middleware registered through the proxy land directly
in the underlying router's layer list, so the original server's handler
picks them up without any extra wiring.

Nesting is supported: route().route() compounds the prefix, and the
context-extension API (use with next(extra)) works identically on proxies.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-05 23:52:29 +00:00
co-authored by Claude
parent 32d2c346e8
commit 6ee7a6d144
4 changed files with 271 additions and 1 deletions
+1
View File
@@ -1,5 +1,6 @@
export {
Router,
RouteProxy,
Server,
ServerConnection,
type Body,
+1
View File
@@ -1,6 +1,7 @@
export { ServerConnection, type ServerConnectionOptions } from "./connection.js"
export {
Router,
RouteProxy,
type Match,
type PartialMatch,
type HandlerMatch,
+166 -1
View File
@@ -1,6 +1,6 @@
import test, { suite } from "node:test"
import assert from "node:assert"
import { Router } from "./router.js"
import { Router, RouteProxy } from "./router.js"
import type { Body } from "../common/types.js"
import type { Context } from "./types.js"
@@ -437,3 +437,168 @@ suite("Router", () => {
})
})
})
suite("RouteProxy", () => {
test("route() returns a RouteProxy", () => {
const r = new Router()
assert.ok(r.route("/api") instanceof RouteProxy)
})
test("get() registers route with prefix prepended", async () => {
const r = new Router()
r.route("/api").get("/users", async ({ res }) => {
res.body = "users"
})
const ctx = makeCtx("GET", "/api/users")
await r.handler(ctx)
assert.strictEqual(ctx.res.body, "users")
})
test("route('/') prefix acts as root", async () => {
const r = new Router()
r.route("/").get("/users", async ({ res }) => {
res.body = "ok"
})
const ctx = makeCtx("GET", "/users")
await r.handler(ctx)
assert.strictEqual(ctx.res.body, "ok")
})
test("get('/') on proxy registers at exactly the prefix", async () => {
const r = new Router()
r.route("/api").get("/", async ({ res }) => {
res.body = "api-root"
})
const ctx = makeCtx("GET", "/api")
await r.handler(ctx)
assert.strictEqual(ctx.res.body, "api-root")
})
test("all HTTP method shorthands work on proxy", async () => {
const r = new Router()
const api = r.route("/api")
for (const [method, fn] of [
["GET", api.get.bind(api)],
["POST", api.post.bind(api)],
["PUT", api.put.bind(api)],
["PATCH", api.patch.bind(api)],
["DELETE", api.delete.bind(api)],
["OPTIONS", api.options.bind(api)],
] as const) {
fn(`/${method.toLowerCase()}`, async () => {})
assert.ok(
"handler" in r.match(makeReq(method, `/api/${method.toLowerCase()}`)),
`${method} route should match`,
)
}
})
test("proxy methods return this for chaining", async () => {
const r = new Router()
const api = r.route("/api")
const result = api
.get("/a", async () => {})
.post("/b", async () => {})
assert.strictEqual(result, api)
})
test("route().route() nests prefixes", async () => {
const r = new Router()
r.route("/api").route("/v1").get("/users", async ({ res }) => {
res.body = "nested"
})
const ctx = makeCtx("GET", "/api/v1/users")
await r.handler(ctx)
assert.strictEqual(ctx.res.body, "nested")
})
test("route() returns RouteProxy of RouteProxy", () => {
const r = new Router()
const api = r.route("/api")
const v1 = api.route("/v1")
assert.ok(v1 instanceof RouteProxy)
})
test("use() on proxy registers middleware scoped to prefix", async () => {
const r = new Router()
const order: string[] = []
r.route("/api").use(async (_, next) => {
order.push("api-mw")
await next()
})
r.get("/api/users", async () => { order.push("handler") })
r.get("/other", async () => { order.push("other-handler") })
await r.handler(makeCtx("GET", "/api/users"))
assert.deepStrictEqual(order, ["api-mw", "handler"])
order.length = 0
await r.handler(makeCtx("GET", "/other"))
assert.deepStrictEqual(order, ["other-handler"])
})
test("use(subpath) on proxy scopes middleware to prefix + subpath", async () => {
const r = new Router()
let mwRan = false
r.route("/api").use("/admin", async (_, next) => {
mwRan = true
await next()
})
r.get("/api/admin/users", async () => {})
r.get("/api/users", async () => {})
await r.handler(makeCtx("GET", "/api/admin/users"))
assert.ok(mwRan, "middleware should run for /api/admin paths")
mwRan = false
await r.handler(makeCtx("GET", "/api/users"))
assert.ok(!mwRan, "middleware should not run for /api/users")
})
test("proxy use() with extending next(extra) propagates to handler", async () => {
const r = new Router()
let captured: unknown = null
r.route("/api").use(async (ctx, next) => {
await next({ role: "admin" })
})
r.get("/api/data", async (ctx) => {
captured = (ctx as typeof ctx & { role: string }).role
})
const ctx = makeCtx("GET", "/api/data")
await r.handler(ctx)
assert.strictEqual(captured, "admin")
})
test("layer() on proxy prepends prefix", async () => {
const r = new Router()
r.route("/api").layer({
path: "/items",
method: "GET",
handler: async (ctx) => {
;(ctx as { res: { body: unknown } }).res.body = "items"
},
})
const ctx = makeCtx("GET", "/api/items")
await r.handler(ctx)
assert.strictEqual(ctx.res.body, "items")
})
test("404 when request does not match prefix", async () => {
const r = new Router()
r.route("/api").get("/users", async () => {})
const ctx = makeCtx("GET", "/other/users")
await r.handler(ctx)
assert.strictEqual(ctx.res.status, 404)
})
test("path params work through proxy", async () => {
const r = new Router()
let capturedId = ""
r.route("/api").get("/users/:id", async ({ keys }) => {
capturedId = (keys as { id: string }).id
})
const ctx = makeCtx("GET", "/api/users/42")
await r.handler(ctx)
assert.strictEqual(capturedId, "42")
})
})
+103
View File
@@ -196,6 +196,10 @@ export class Router<T extends object = {}> {
}
}
route(path: string): RouteProxy<T> {
return new RouteProxy(this, path)
}
#handler?: Handler
get handler(): Handler {
if (this.#handler) return this.#handler
@@ -245,3 +249,102 @@ export class Router<T extends object = {}> {
})
}
}
type LayerOptions = {
path?: string
method?: string | string[] | ReadonlySet<string> | null
middlewares?: AnyFn[]
handler?: AnyFn | null
loose?: boolean
global?: boolean
}
export class RouteProxy<T extends object = {}> {
readonly #router: Router<T>
readonly #prefix: string
constructor(router: Router<T>, prefix: string) {
this.#router = router
this.#prefix = prefix
}
#join(path: string): string {
if (this.#prefix === "/") return path.startsWith("/") ? path : "/" + path
const base = this.#prefix.endsWith("/") ? this.#prefix.slice(0, -1) : this.#prefix
if (path === "/" || path === "") return base
return base + (path.startsWith("/") ? path : "/" + path)
}
#method(method: string, path: string, middlewaresAndHandler: unknown[]): void {
const handler = middlewaresAndHandler.at(-1) as AnyFn
const middlewares = middlewaresAndHandler.slice(0, -1) as AnyFn[]
this.#router.layer({ path: this.#join(path), method, middlewares, handler })
}
get(path: string, ...rest: [...Middleware<T & Match>[], Handler<T & Match>]): this {
this.#method("GET", path, rest)
return this
}
post(path: string, ...rest: [...Middleware<T & Match>[], Handler<T & Match>]): this {
this.#method("POST", path, rest)
return this
}
put(path: string, ...rest: [...Middleware<T & Match>[], Handler<T & Match>]): this {
this.#method("PUT", path, rest)
return this
}
patch(path: string, ...rest: [...Middleware<T & Match>[], Handler<T & Match>]): this {
this.#method("PATCH", path, rest)
return this
}
delete(path: string, ...rest: [...Middleware<T & Match>[], Handler<T & Match>]): this {
this.#method("DELETE", path, rest)
return this
}
options(path: string, ...rest: [...Middleware<T & Match>[], Handler<T & Match>]): this {
this.#method("OPTIONS", path, rest)
return this
}
// Context-extending overloads — same ordering convention as Router.use()
use<TAdd extends object>(
middleware: (ctx: Context<T & Match>, next: (extra: TAdd) => Promise<void>) => Promise<void>,
): RouteProxy<T & TAdd>
use<TAdd extends object>(
path: string,
middleware: (ctx: Context<T & Match>, next: (extra: TAdd) => Promise<void>) => Promise<void>,
): RouteProxy<T & TAdd>
// Non-extending overloads
use(...middlewares: Middleware<T & Match>[]): this
use(path: string, ...middlewares: Middleware<T & Match>[]): this
use(options: { path?: string; global?: false }, ...middlewares: Middleware<T & Match>[]): this
use(options: { path?: string; global: true }, ...middlewares: Middleware<PartialMatch & T>[]): this
use(path: string, options: { global?: false }, ...middlewares: Middleware<T & Match>[]): this
use(path: string, options: { global: true }, ...middlewares: Middleware<PartialMatch & T>[]): this
use(...pathAndmiddlewares: unknown[]): unknown {
if (!pathAndmiddlewares.length) return this
let path: string = "/"
let global = false
if (typeof pathAndmiddlewares[0] === "string") {
path = pathAndmiddlewares.shift() as string
}
if (typeof pathAndmiddlewares[0] === "object" && pathAndmiddlewares[0] !== null) {
const options = pathAndmiddlewares.shift() as { path?: string; global?: boolean }
if (options.global) global = true
if (options.path) path = options.path
}
const middlewares = pathAndmiddlewares as AnyFn[]
this.#router.layer({ path: this.#join(path), middlewares, loose: true, global })
return this
}
layer({ path = "/", ...rest }: LayerOptions): void {
this.#router.layer({ path: this.#join(path), ...rest })
}
route(subpath: string): RouteProxy<T> {
return new RouteProxy(this.#router, this.#join(subpath))
}
}