◆ /foo/bar の中でだけ使える機能を提供するプラグインを作りたい
◆ テンプレートの autoload 方法だとプラグインは全体が対象になる
   ◆ 特定のプレフィックスの中でカプセル化できない
◆ autoload をネストするか autohooks を使うとできるけど工夫が必要になる

Fastify CLI のテンプレート

Fastify CLI のテンプレートで作ると こういうフォルダ構造になります

package.json
app.js
plugins/
plugin1.js
routes/
route1.js

app.js は Fastify CLI からロードされるプラグインで 中の処理は plugins と routes フォルダを autoload に登録するものです
autoload の登録のところは触らないで というコメントが書かれてるくらいなのでこのまま使うのが推奨されてるようです
autoload の登録の手前ではユーザーがコードを追加してカスタムできる場所はあるようですが ここでなにかするならプラグインを作ればいいと思うので基本 app.js は触らないものという扱いで良いと思います

https://github.com/fastify/fastify-cli/blob/v6.1.1/templates/app-esm/app.js
export default async function (fastify, opts) {
// Place here your custom code!

// Do not touch the following lines

// This loads all plugins defined in plugins
// those should be support plugins that are reused
// through your application
fastify.register(AutoLoad, {
dir: path.join(__dirname, 'plugins'),
options: Object.assign({}, opts)
})

// This loads all plugins defined in routes
// define your routes in one of these
fastify.register(AutoLoad, {
dir: path.join(__dirname, 'routes'),
options: Object.assign({}, opts)
})
}

プラグインは plugins フォルダで ルート定義は routes フォルダ内に配置する感じになっています
この中に置いた .js ファイルが自動で読み込まれます

プラグインは fp (fastify-plugin) を使うことが推奨されています
fp を使うとカプセル化されません
親にカプセル化される部分がないのでグローバルなプラグインになります
反対にルートの方は fp を使わないのでカプセル化されます

困るところ

そうなると特定の範囲のルートでだけ有効なプラグインを作りたい場合に困るように思います
例えば /api/v1 以下のみ fastify.db にコネクションを保持して また /api/v2 なら別のコネクションを保持したいとかあると思います
ですがこの autoload のしくみだと plugins の中で fastify.decorate すると全体に影響します
routes の routes/api/v1 で fastify.decorate すると そのファイル内で定義したルートでは有効ですが routes/api/v1/foo など別のファイルのプラグインになるとそこでは有効ではないです

プラグインの登録では ルート単位でのカプセル化ではなく register メソッドの呼び出しのネストでカプセル化されるので 独立して register するとルートが同じでも別物になるのですよね
plugins/api/v1.js を fp なしで作ったとしても ここで追加したプロパティを routes/api/v1.js 側で使えません

routes/api/v1.js で /api/v1 以下のルートをすべて定義するなら問題ないです

export default async (fastify, opts) => {
fastify.decorate("db", createConnection(opts))

// /api/v1/foo.js
fastify.get("/foo", async () => {
// ...
})

// /api/v1/bar.js
fastify.get("/bar", async () => {
// ...
})

// ...
}

ですが ルートの数が多く ほとんどのルートがこの中にある場合 routes フォルダの autoload があまり意味のないものになってしまいます

やりたいこと

autoload プラグインを使わず自分でプラグインを register していくときのように 指定範囲内でのみ有効なプラグインを使えるようにしたいです

とはいえ

/
/foo/bar/baz
/baz/foo

でだけ共有とかするのは複雑ですし 実際にそれが必要なケースもあまりないと思います
なのでルートを基準にして /foo/bar/ 以下のみ有効みたいなものができればよいことにします

例)

import Fastify from "fastify"
import fp from "fastify-plugin"

const fastify = Fastify()

await fastify.register(fp(async (fastify, opts) => {
fastify.decorate("value1", "VALUE")
}))

await fastify.register(async (fastify, opts) => {
await fastify.register(async (fastify, opts) => {
await fastify.register(fp(async (fastify, opts) => {
fastify.decorate("value2", "VALUE")
}))

await fastify.register(async (fastify, opts) => {
fastify.get("/route1", async (request, reply) => {
console.log("route1", fastify.value1, fastify.value2) // VALUE VALUE
// ...
})
}, { prefix: "/baz" })

fastify.get("/route2", async (request, reply) => {
console.log("route2", fastify.value1, fastify.value2) // VALUE VALUE
// ...
})

fastify.get("/route3", async (request, reply) => {
console.log("route3", fastify.value1, fastify.value2) // VALUE VALUE
// ...
})
}, { prefix: "/bar" })

fastify.get("/route4", async (request, reply) => {
console.log("route4", fastify.value1, fastify.value2) // VALUE undefined
// ...
})
}, { prefix: "/foo" })

fastify.get("/route5", async (request, reply) => {
console.log("route5", fastify.value1, fastify.value2) // VALUE undefined
// ...
})

await fastify.inject({ method: "GET", url: "/foo/bar/baz/route1" })
await fastify.inject({ method: "GET", url: "/foo/bar/route2" })
await fastify.inject({ method: "GET", url: "/foo/bar/route3" })
await fastify.inject({ method: "GET", url: "/foo/route4" })
await fastify.inject({ method: "GET", url: "/route5" })

これと同じものを作れればおっけいとします

autoload のネスト

上でも書いたように Fastify のカプセル化は register のネストで制御されるものです
autoload で別々にロードされるファイルだとどうしようもないと思うので autoload をネストするようにしようと思います

routes/foo/bar でカプセル化されたコンテキストの中で autoload の登録を行います
そうすればその中の plugins と routes は /foo/bar 以下でのみ有効になるはずです

app.js : autoload を設定
→ plugins : グローバルのプラグイン
→ routes : グローバルのルート定義
→ foo/bar : ここで autoload を設定
→ foo/bar/plugins : /foo/bar 内のプラグイン
→ foo/bar/routes : /foo/bar 内のルート定義

みたいなイメージです

ですが app.js で設定した autoload は foo/bar/plugins や foo/bar/routes もグローバルなものとしてロードしてしまいます
autoload 機能では index.js があるとそのフォルダ内の autoload は index.js だけになるらしいので routes/foo/bar/index.js を置いてみます
これで routes/foo/bar/plugins や routes/foo/bar/routes が無視されてくれればよかったのですがなぜかロードされてしまいました

index.js と同じ階層の .js ファイルはスキップされてるようなのですが サブディレクトリになるとまたロードされてしまうようです
index.js がそのサブディレクトリからモジュールを import とか普通にありそうなので バグか考慮不足ではと思ったのですがドキュメントを見るとこれが意図したものになってそうです
「In that case only the index file (and the potential sub-directories) will be loaded.」

対策(1)

app.js があるフォルダに別の plugins や routes フォルダも置いて そこを autoload に設定することもできます

package.json
app.js
plugins/
plugin1.js
routes/
foo/
bar/
index.js
foo_bar-plugins/
plugin2.js
foo_bar-routes/
route2.js

routes/foo/bar/index.js が foo_bar-plugins と foo_bar-routes を autoload に設定する形です
でもこれって foo_bar に当たる部分がいっぱい出てくると管理が大変です

もう少し工夫もできますが ツリーが別になるのは微妙な感じがします

package.json
app.js
plugins/
plugin1.js
routes/
foo/
bar/
index.js
mount/
foo/
bar/
plugins/
plugin2.js
routes/
route2.js

対策(2)

「index.js がある場合に その階層のフォルダを autoload の対象外にする」 というのは難しそうですが 無視するパターンを設定することはできます
今回は plugins と routes というフォルダ名で固定なのでこれでやってみます

ignorePattern に /^(routes|plugins)$/ をつけてこれらのフォルダは除外します

fastify.register(AutoLoad, {
dir: path.join(dirname, "routes"),
options: Object.assign({}, opts),
ignorePattern: /^(routes|plugins)$/
})

package.json
app.js
plugins/
plugin1.js
routes/
foo/
bar/
index.js
plugins/
plugin2.js
routes/
route2.js

最終的に

対策(2) の方法を使って上に書いた例と同じのを実現してみます
フォルダ構造はこうなります

.
|-- app.js
|-- autoload.js
|-- plugins
| `-- value1.js
`-- routes
|-- foo
| |-- bar
| | |-- index.js
| | |-- plugins
| | | `-- value2.js
| | `-- routes
| | |-- baz
| | | `-- route1.js
| | |-- route2.js
| | `-- route3.js
| `-- route4.js
`-- route5.js

app.js と routes/foo/bar/index.js がエクスポートするものは共通でこうします

export default async function (fastify, opts) {
autoload(fastify, import.meta.url, opts)
}

この autoload 関数は autoload.js がエクスポートするもので こういうものです

export default (fastify, base_url, options) => {
const dirname = toDirName(base_url)
const { prefix: _, ...opts } = options

// This loads all plugins defined in plugins
// those should be support plugins that are reused
// through your application
fastify.register(AutoLoad, {
dir: path.join(dirname, "plugins"),
options: Object.assign({}, opts)
})

// This loads all plugins defined in routes
// define your routes in one of these
fastify.register(AutoLoad, {
dir: path.join(dirname, "routes"),
options: Object.assign({}, opts),
ignorePattern: /^(routes|plugins)$/
})
}

デフォルトのテンプレートでこういうネストすることを想定しておいてほしいですが してないということはあまり推奨されなかったりするのでしょうか
書き換えないでって言われてる autoload の設定を書き換えてますし

ただ期待する動作にはなったのでとりあえずこれでいいかなと思います

autohooks

近そうなもので autoload のプラグインには autohooks という機能があります
これを使うとフォルダ内の autohooks.js をロードして そのカプセル化範囲がフォルダ内のファイル全体になります
cascadeHooks を true にするとサブディレクトリでも有効になります

名前的に Fastify のフックを設定する用途みたいですけど ただのプラグインなので decorate も使えそうです
index.js の代わりに autohooks.js を使って そのルート以下で有効にしたい decorate などを行えば上でやったのと似たようなことができそうです

ただ autoload のように plugins と routes できれいに分けて書けないのはイマイチに感じます
また Fastify CLI のテンプレートでは有効になってないものです

個人的には autoload をネストする考え方の方が好きです

ソースコード

1 ファイルで手動 register するケースと autoload をネストするケースと autohooks を使うケースで実際に動くソースコードと結果はこんな感じです
https://gitlab.com/nexpr/fastify-scoped-plugin

各方法で fastify インスタンスを作って printRoutes と printPlugins をした結果と それぞれのルートへアクセスして表示されるものをまとめています
実際の各ファイルはリポジトリの方を参照です

import Fastify from "fastify"
import manual from "./manual/app.js"
import nested_autoload from "./nested-autoload/app.js"
import autohooks from "./autohooks/app.js"

const run = async (app) => {
const fastify = Fastify()
await fastify.register(app)

console.log(fastify.printRoutes())
console.log(fastify.printPlugins())

await fastify.inject({ method: "GET", url: "/foo/bar/baz/route1" })
await fastify.inject({ method: "GET", url: "/foo/bar/route2" })
await fastify.inject({ method: "GET", url: "/foo/bar/route3" })
await fastify.inject({ method: "GET", url: "/foo/route4" })
await fastify.inject({ method: "GET", url: "/route5" })
}

console.log("\n==== manual ====\n")

await run(manual)

console.log("\n==== nested autoload ====\n")

await run(nested_autoload)

console.log("\n==== autohooks ====\n")

await run(autohooks)

出力

==== manual ====

└── /
├── foo/
│ ├── bar/
│ │ ├── baz/route1 (GET, HEAD)
│ │ └── route
│ │ ├── 2 (GET, HEAD)
│ │ └── 3 (GET, HEAD)
│ └── route4 (GET, HEAD)
└── route5 (GET, HEAD)

root -1 ms
├── bound _after 5 ms
├─┬ default 11 ms
│ ├── app-auto-0 0 ms
│ ├── bound _after 1 ms
│ ├─┬ foo 9 ms
│ │ ├─┬ bar 7 ms
│ │ │ ├── app-auto-1 0 ms
│ │ │ ├── bound _after 1 ms
│ │ │ ├─┬ baz 2 ms
│ │ │ │ ├── bound _after 1 ms
│ │ │ │ └── bound _after 0 ms
│ │ │ ├── bound _after 1 ms
│ │ │ ├── bound _after 0 ms
│ │ │ ├── bound _after 0 ms
│ │ │ ├── bound _after 0 ms
│ │ │ └── bound _after 0 ms
│ │ ├── bound _after 0 ms
│ │ ├── bound _after 0 ms
│ │ └── bound _after 0 ms
│ ├── bound _after 0 ms
│ ├── bound _after 0 ms
│ └── bound _after 0 ms
└── bound _after 0 ms

route1 VALUE VALUE
route2 VALUE VALUE
route3 VALUE VALUE
route4 VALUE undefined
route5 VALUE undefined

==== nested autoload ====

└── /
├── route5 (GET, HEAD)
└── foo/
├── route4 (GET, HEAD)
└── bar/
├── route
│ ├── 2 (GET, HEAD)
│ └── 3 (GET, HEAD)
└── baz/route1 (GET, HEAD)

root -1 ms
├── bound _after 2 ms
├─┬ default 30 ms
│ ├─┬ autoload 8 ms
│ │ └── value1-auto-2 0 ms
│ └─┬ autoload 22 ms
│ ├─┬ default 1 ms
│ │ ├── bound _after 0 ms
│ │ └── bound _after 0 ms
│ ├─┬ default 1 ms
│ │ ├── bound _after 0 ms
│ │ └── bound _after 0 ms
│ └─┬ default 14 ms
│ ├─┬ autoload 4 ms
│ │ └── value2-auto-3 0 ms
│ └─┬ autoload 9 ms
│ ├─┬ default 1 ms
│ │ ├── bound _after 0 ms
│ │ └── bound _after 0 ms
│ ├─┬ default 1 ms
│ │ ├── bound _after 0 ms
│ │ └── bound _after 0 ms
│ └─┬ default 1 ms
│ ├── bound _after 1 ms
│ └── bound _after 0 ms
└── bound _after 0 ms

route1 VALUE VALUE
route2 VALUE VALUE
route3 VALUE VALUE
route4 VALUE undefined
route5 VALUE undefined

==== autohooks ====

└── /
├── route5 (GET, HEAD)
└── foo/
├── route4 (GET, HEAD)
└── bar/
├── route
│ ├── 2 (GET, HEAD)
│ └── 3 (GET, HEAD)
└── baz/route1 (GET, HEAD)

root -1 ms
├── bound _after 1 ms
├─┬ default 27 ms
│ ├─┬ autoload 5 ms
│ │ └── value1-auto-4 0 ms
│ └─┬ autoload 22 ms
│ ├─┬ default 1 ms
│ │ ├── bound _after 0 ms
│ │ └── bound _after 0 ms
│ ├─┬ default 1 ms
│ │ ├── bound _after 1 ms
│ │ └── bound _after 0 ms
│ ├─┬ composedPlugin 1 ms
│ │ ├── default 0 ms
│ │ ├─┬ default 1 ms
│ │ │ ├── bound _after 0 ms
│ │ │ └── bound _after 0 ms
│ │ └─┬ default 0 ms
│ │ ├── bound _after 0 ms
│ │ └── bound _after 0 ms
│ └─┬ composedPlugin 1 ms
│ ├── default 0 ms
│ └─┬ default 0 ms
│ ├── bound _after 0 ms
│ └── bound _after 0 ms
└── bound _after 0 ms

route1 VALUE VALUE
route2 VALUE VALUE
route3 VALUE VALUE
route4 VALUE undefined
route5 VALUE undefined