◆ 他のミドルウェアライブラリと近い感じで設定風にミドルウェアを作れる
◆ hapi 風

ルーティングライブラリだけちょっと特殊

Koa のミドルウェアライブラリはいろいろありますが 特にどれにも不満はありませんでした
しかし ルーティングだけが特別です

Koa の有名なルーティングミドルウェアに koa-route@koa/router があります
koa-route はルートひとつひとつをミドルウェアとして登録するもので いまいち使い勝手がよくないです
そのため 一般的には @koa/router のほうが使われています
@koa/router では Router のインスタンスを作ってそこでルーティングの設定をしてから ミドルウェアを出力するメソッドを実行し その結果を app.use する流れです

const router = new Router()
router.get("/", middleware1)
router.post("/post", middleware2)

app.use(router.routes())

他のほとんどのミドルウェアは基本的に app.use の引数部分でライブラリの関数を実行してその結果のミドルウェアを設定する作りになってます
設定はミドルウェアを作る関数の引数にオプションを入れるのみです

app.use(somemiddleware({ options }))

なので app.use が並んでいるところで @koa/router だけ浮いてるし 何度も Koa を使ったアプリケーションを作ってるとルーティング部分だけがなんかもっと使いやすくならないかなと思います

設定ベースのミドルウェアを作る

他がオプションとして渡したオブジェクトによる設定のみということに合わせて メソッド実行ではなくオプションで設定するようなものを作ることにします

ルーティングの場合はキーバリューというより ルートの配列が妥当だと思うのでオプションはルートの設定オブジェクトの配列にしました
ルート設定は 設定ファイル風に書ける hapi を参考にしました
ただ hapi はルートごとに全部の設定があるようなフレームワークなのに対し Koaはミドルウェアベースでルーティング以前のミドルウェアでほとんどの設定がされているので @koa/router で設定するようなパスとメソッドとそれに対するミドルウェア関数を設定するくらいです

簡単な使用例ではこう使えるようにします

app.use(routes([
{
path: "/",
method: "GET",
handler(ctx) {
ctx.body = "ok"
}
},
{
path: "/post",
method: "POST",
handler(ctx) {
ctx.body = "POSTed"
}
}
]))

また path, method, handler はすべて配列でも設定できるようにします
配列の場合はどれかに当てはまればそのルートが使用されます
handler が配列の場合はそれらを compose した関数をミドルウェアとします

path は文字列と正規表現に対応していて 文字列の場合は正規表現に変換してマッチングチェックします
文字列中に 「<name>」 というのがあると 任意の文字列にマッチングし name パラメータとして ctx.params で参照可能になります
任意文字列には 「/」 も含みますが 「<name/>」 のように最後に 「/」 を入れると 「/」 は任意文字列に含まれなくなります

「/」 を書くんだから 「/」 ありが 「/」 を含むという意味のほうがわかりやすいと感じるかもしれません
しかし 元々はパラメータには 「/」 は使わず 「/」 を含むすべての文字にマッチして 「/」 を含みたくないなら外側に 「/」 を書けば良くて 「/foo/<bar>/」 と設定すればパラメータ側のマッチング方法に影響させなくて良いというものでした
ただ それだと末尾 「/」 が必須になって 「/foo/bar」 みたいな URL にマッチできません
なので 「/」 をパラメータの内側に持ってきて 「/foo/<bar/>」 という書き方にしました
外側に 「/」 があるイメージと考えてもらえると良いです

正規表現の場合はそのまま使われるので params で名前で参照したいなら (?<name>.*) のように名前付きキャプチャするように書いておく必要があります
また そのまま使われるので ^$ も必要です

ライブラリコードは長いので最後にして 先に Koa で使ってみてリクエストを送った結果の例です

const Koa = require("koa")
const routes = require("./routes.js")

const app = new Koa()

app.use(
routes([
{
path: "/",
method: "GET",
handler(ctx) {
ctx.body = "A"
},
},
{
path: ["/a", "/b"],
method: ["GET", "POST"],
handler: [
(ctx, next) => {
ctx.body = "M"
return next()
},
ctx => (ctx.body += "B"),
],
},
{
path: /^\/c\/xx-(?<id>[a-z]+)-yy\/zz$/,
handler(ctx) {
ctx.body = ctx.params
}
},
{
path: "/d/xx-<id>-yy/zz",
handler(ctx) {
ctx.body = ctx.params
}
},
{
path: "e/zz<param>",
handler(ctx) {
ctx.body = ctx.params
}
},
{
path: "f/zz<param/>",
handler(ctx) {
ctx.body = ctx.params
}
},
{
handler(ctx) {
ctx.body = "NOTFOUND"
}
},
])
)

app.listen(80)

このサーバへリクエストを送ります

const request = async (path, method) => {
const res = await fetch(path, { method })
const body = await res.text()
console.log(body)
}

await request("/")
// "A"

await request("/", "POST")
// "NOTFOUND"

await request("/a")
// "MB"

await request("/a", "POST")
// "MB"

await request("/b")
// "MB"

await request("/b", "POST")
// "MB"

await request("/c/xx-abcd-yy/zz")
// "{"id":"abcd","match1":"abcd"}"

await request("/d/xx-123-yy/zz")
// "{"id":"123","match1":"123"}"

await request("/e/zz1/2/3/4")
// "{"param":"1/2/3/4","match1":"1/2/3/4"}"

await request("/f/zz1/2/3/4")
// "NOTFOUND"

await request("/f/zz1234")
// "{"param":"1234","match1":"1234"}"

「/」 は GET のみなので POST の場合は一番最後のルートにマッチし NOTFOUND です
「/a」 と 「/b」 は GET/POST 両方に対応してるので どちらも MB となります
関数の配列の場合は普通のミドルウェアと同じように next で次を実行なので M と B はくっついて MB になってます

「/c」 と 「/d」 は id という名前でパラメータ保存する例です
最初の c と d 以外は同じ条件で文字列で書いた場合と正規表現で書いた場合の違いです
基本は文字列で書いたが方がシンプルでわかりやすいです

「/e」 と 「/f」 ですが パラメータの最後に 「/」 がある場合と無い場合の動きの違いです
パラメータに 「/」 がない場合はパス中の 「/」 を含んでマッチするので 「/e/zz1/2/3/4」 にマッチします
パラメータに 「/」 がある場合はパス中の 「/」 にマッチしないので 「/f/zz1/2/3/4」 にはマッチせず 「/」 がない 「/f/zz1234」 にマッチします

コード

const compose = require("koa-compose")

const toarray = a => (Array.isArray(a) ? a : a == null ? [] : [a])

const path2regexp = path => {
if (!path[0] !== "/") path = "/" + path
const repath = path
.split(/<([_a-z0-9]*\/?)>/i)
.map((part, i) => {
if (i % 2) {
const any = part.slice(-1) === "/" ? "[^/]" : "."
const name = part.slice(-1) === "/" ? part.slice(0, -1) : part
return `(?<${name}>${any}*?)`
} else {
return [...part].map(e => "\\u" + e.charCodeAt().toString(16).padStart(4, "0")).join("")
}
})
.join("")
return new RegExp(`^${repath}$`, "i")
}

module.exports = (routes = []) => {
const middlewares = routes.map(route => (ctx, next) => {
if (
route.path &&
!toarray(route.path).some(p => {
const regexp = p instanceof RegExp ? p : path2regexp(String(p))
const matched = regexp.exec(ctx.path)
if (!matched) return false
const params = (ctx.params = {})
if (matched.length > 1) {
Object.assign(params, matched.groups)
for (const [index, value] of matched.slice(1).entries()) {
params["match" + (index + 1)] = value
}
}
return true
})
)
return next()
if (
route.method &&
!toarray(route.method).some(m => {
return m.toUpperCase() === ctx.method || m.toUpperCase() === "ALL"
})
)
return next()
return Promise.resolve(compose(toarray(route.handler))(ctx, next))
})

return compose(middlewares)
}