diff --git a/.vitepress/config.ts b/.vitepress/config.ts index 261f5dc8..c523a67c 100644 --- a/.vitepress/config.ts +++ b/.vitepress/config.ts @@ -298,6 +298,10 @@ export const sidebarsExamples = (): DefaultTheme.SidebarItem[] => [ text: 'Hono Docs Generator', link: '/examples/hono-docs', }, + { + text: 'InferDI (Dependency Injection)', + link: '/examples/inferdi', + }, ], }, { diff --git a/examples/inferdi.md b/examples/inferdi.md new file mode 100644 index 00000000..48e42753 --- /dev/null +++ b/examples/inferdi.md @@ -0,0 +1,147 @@ +# InferDI + +[InferDI](https://github.com/inferdi/inferdi) is a zero-dependency, decorator-free, strongly typed dependency injection container for TypeScript. The [`@inferdi/hono`](https://www.npmjs.com/package/@inferdi/hono) middleware wires it into Hono's request pipeline: it creates one DI scope per request, exposes it on the context as `c.var.di`, and disposes it after the response completes — no decorators, reflection, or route scanning. + +The graph _is_ the type: a misordered dependency, a missing key, or a request-scoped value leaking into a singleton are all compile errors, not runtime surprises. + +## 🛠️ Installation + +```bash +npm install @inferdi/inferdi @inferdi/hono +``` + +> [!NOTE] +> InferDI ships on both npm and JSR. On Deno install it with `deno add jsr:@inferdi/inferdi jsr:@inferdi/hono npm:hono`. + +## 🚀 Getting Started + +### 1. Build a container + +Register your services on a root `Container`. Dependencies are passed as a tuple of keys and type-checked positionally against the constructor — a wrong order or type is a compile error. Each registration declares a lifetime: `singleton` (default, one instance per container), `scoped` (one per request), or `transient` (new on every resolve). + +```ts +// container.ts +import { Container } from '@inferdi/inferdi' + +export function buildRootContainer() { + return ( + new Container() + .registerClass('logger', Logger, []) + // `request` is scoped: a fresh instance per request scope. + .registerClass('request', RequestContext, [], 'scoped') + // `users` is scoped too — it depends on the scoped `request`. + .registerClass( + 'users', + UserService, + ['logger', 'request'], + 'scoped' + ) + ) +} +``` + +> [!NOTE] +> A `singleton` cannot depend on a `scoped` or `transient` service — that would leak a short-lived value into a long-lived one, and InferDI rejects it at compile time. Keep request-bound services `scoped`. + +### 2. Add the middleware + +`inferdiHono` creates a request scope before your handlers run and disposes it afterwards. `InferdiHonoEnv` types `c.var.di` as your concrete scope, so `.get(key)` stays fully typed. + +```ts +import { Hono } from 'hono' +import { inferdiHono, type InferdiHonoEnv } from '@inferdi/hono' +import { buildRootContainer } from './container' + +const root = buildRootContainer() +const app = new Hono>() + +app.use('*', inferdiHono({ container: root })) + +export default app +``` + +### 3. Hydrate the request scope + +Use `setupScope` to fill request-scoped services with per-request data (request id, authenticated user, …) before any handler sees the scope. It runs once per request and may be async. + +```ts +app.use( + '*', + inferdiHono({ + container: root, + setupScope: (scope, c) => { + const request = scope.get('request') + request.requestId = crypto.randomUUID() + request.userId = c.req.header('x-user-id') + }, + }) +) +``` + +### 4. Resolve services in handlers + +Resolve any registered service from the request scope with `c.var.di.get(key)`. The returned value is fully typed, and scoped services share one instance for the whole request. + +```ts +app.get('/users/:id', async (c) => { + const user = await c.var.di.get('users').profile(c.req.param('id')) + return c.json(user) +}) +``` + +`c.get('di')` is equivalent to `c.var.di`. To use a different context key, pass `key` and reflect it in the env type: + +```ts +type AppEnv = InferdiHonoEnv + +const app = new Hono() +app.use('*', inferdiHono({ container: root, key: 'container' })) + +app.get('/users/:id', (c) => + c.json(c.var.container.get('users').profile(c.req.param('id'))) +) +``` + +## ⚙️ Options + +`inferdiHono` accepts the following options: + +| Option | Default | Description | +| ---------------- | -------------------- | ------------------------------------------------------------------------- | +| `container` | — | **Required.** The root container. The middleware never disposes the root. | +| `key` | `'di'` | Context variable key used for `c.var[key]` / `c.get(key)`. | +| `createScope` | `root.createScope()` | Overrides how the request scope is created. May be async. | +| `setupScope` | — | Hydrates the scope before handlers run. May be async. | +| `disposeScope` | `scope.dispose()` | Overrides request-scope disposal. May be async. | +| `autoDispose` | `true` | Set to `false` (or return `false`) when application code owns disposal. | +| `onDisposeError` | `console.error` | Sink for post-response disposal failures. | + +## 🌊 Streaming + +A streaming response returns before the stream callback finishes, so disable auto-disposal with `skipInferdiDispose(c)` and dispose the scope yourself when the stream ends. + +```ts +import { stream } from 'hono/streaming' +import { skipInferdiDispose } from '@inferdi/hono' + +app.get('/events', (c) => { + skipInferdiDispose(c) + const scope = c.var.di + const events = scope.get('events') + + return stream(c, async (s) => { + try { + for await (const event of events.subscribe()) { + await s.write(`data: ${JSON.stringify(event)}\n\n`) + } + } finally { + await scope.dispose() + } + }) +}) +``` + +## See also + +- [InferDI Hono adapter docs](https://inferdi.com/adapters/hono) +- [`@inferdi/hono` on GitHub](https://github.com/inferdi/inferdi/tree/main/packages/hono)