ioc-manifest 1.5.0 → 1.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -1065
- package/package.json +9 -5
package/README.md
CHANGED
|
@@ -2,1084 +2,41 @@
|
|
|
2
2
|
|
|
3
3
|
**Convention-based dependency discovery and codegen for [Awilix](https://github.com/jeffijoe/awilix).** Write factory functions, run the generator, get a fully typed IoC container — no manual registrations. Compose containers across packages in a monorepo with first-class support.
|
|
4
4
|
|
|
5
|
-
```
|
|
6
|
-
npm install ioc-manifest
|
|
7
|
-
```
|
|
8
|
-
|
|
9
|
-
---
|
|
10
|
-
|
|
11
|
-
## The problem
|
|
12
|
-
|
|
13
|
-
In most Node.js DI setups, every new service means another `container.register(...)` call, another import, another string key to keep in sync. Scale that to 50+ services and registration code becomes a maintenance burden. Awilix's `loadModules` helps, but you lose type safety — `container.resolve("userService")` returns `any` unless you maintain a cradle type by hand.
|
|
14
|
-
|
|
15
|
-
And once you have more than one package in a monorepo, the registration story gets worse: either one app's bootstrap scans into every other package's source (fragile, fights TypeScript's module resolution), or you duplicate registration glue everywhere.
|
|
16
|
-
|
|
17
|
-
## What this does
|
|
18
|
-
|
|
19
|
-
`ioc-manifest` scans your TypeScript source at **build time**, discovers factory functions by naming convention, infers their contracts and dependencies from the type system, and generates manifest and types files that hand directly to Awilix.
|
|
20
|
-
|
|
21
|
-
For a single-package project, that's two generated files:
|
|
22
|
-
|
|
23
|
-
1. **`ioc-manifest.ts`** — a registration manifest with every factory, its contract, lifetime, and module import
|
|
24
|
-
2. **`ioc-registry.types.ts`** — a fully typed `IocGeneratedCradle` interface for your container, plus an `IocExternals` interface describing dependencies the package expects from outside
|
|
25
|
-
|
|
26
|
-
For a monorepo where one app composes manifests from multiple packages, a third file appears in the app:
|
|
27
|
-
|
|
28
|
-
3. **`ioc-composed.ts`** — the composition glue: imports each package's manifest, intersects their cradle types into a single `AppCradle`, and emits compile-time assertions that every package's externals are satisfied.
|
|
29
|
-
|
|
30
|
-
Every factory is registered with the correct key and lifetime, the container is fully typed end-to-end, and you never write a registration line again.
|
|
31
|
-
|
|
32
|
-
The approach is loosely inspired by [StructureMap](https://structuremap.github.io/)'s registry scanning conventions from the .NET world — convention over configuration, with a single config file as the policy surface when you need to override defaults.
|
|
33
|
-
|
|
34
|
-
## What you get for free
|
|
35
|
-
|
|
36
|
-
- **Auto-discovery** — export `buildUserService` and it's registered as `userService` returning `UserService`
|
|
37
|
-
- **Typed container** — `container.resolve("userService")` returns `UserService`, not `any`
|
|
38
|
-
- **Plural collections** — two implementations of `MediaStorage` automatically get a `mediaStorages: ReadonlyArray<MediaStorage>` key
|
|
39
|
-
- **Default selection** — convention picks the default; override in config when you have multiple implementations
|
|
40
|
-
- **Externals are explicit** — every dependency a package expects from outside is tracked in a generated `IocExternals` interface
|
|
41
|
-
- **Cross-package composition** — apps in a monorepo can compose manifests from multiple packages with no scanning across boundaries
|
|
42
|
-
- **Compile-time satisfaction checks** — when composing, TypeScript fails compilation if any composed package's externals aren't satisfied
|
|
43
|
-
- **Lifetime-inversion safety** — generation fails when a longer-lived service would freeze a shorter-lived dependency (a singleton holding a scoped repository), catching a whole class of stale-state bugs statically
|
|
44
|
-
- **`ioc validate`** — a CI-friendly command that reports every cross-manifest problem at once
|
|
45
|
-
- **Works in dev and prod** — discovers from TypeScript source during development, and works just as well against a bundled single-file production build (see [Dev and production builds](#dev-and-production-builds))
|
|
46
|
-
|
|
47
|
-
---
|
|
48
|
-
|
|
49
|
-
## Library mode vs app mode
|
|
50
|
-
|
|
51
|
-
`ioc-manifest` has two modes. Which one applies depends on a single config field: `composedManifests`.
|
|
52
|
-
|
|
53
|
-
**Library mode** is the default. A package generates its own manifest and types. Factories in that package can declare dependencies on things the package itself supplies (other local factories) _and_ on things it expects from outside (externals). The generated `IocExternals` interface documents the external contract — what the package needs to be handed at composition time.
|
|
54
|
-
|
|
55
|
-
**App mode** is what you turn on when a package composes manifests from other packages. The app declares `composedManifests: ['@scope/pkg-a', '@scope/pkg-b']` in its config. Codegen produces the extra `ioc-composed.ts` file, intersects the participating cradle types, and emits the compile-time assertion that every composed package's externals are satisfied somewhere in the composition.
|
|
56
|
-
|
|
57
|
-
A single-package project stays in library mode and never thinks about composition. A monorepo with one or more apps that consume shared packages has library-mode packages and one or more app-mode apps.
|
|
58
|
-
|
|
59
|
-
The quick start below walks through library mode. App mode is covered in [Cross-package composition](#cross-package-composition).
|
|
60
|
-
|
|
61
|
-
---
|
|
62
|
-
|
|
63
|
-
## Quick start
|
|
64
|
-
|
|
65
|
-
This walks through a single-package setup in library mode.
|
|
66
|
-
|
|
67
|
-
### 1. Create factories
|
|
68
|
-
|
|
69
|
-
Write plain factory functions. The naming convention `build<Name>` is the only requirement. Each factory's first parameter is a **named local deps type** describing what it consumes.
|
|
70
|
-
|
|
71
|
-
```ts
|
|
72
|
-
// src/services/buildUserRepository.ts
|
|
73
|
-
export type UserRepository = {
|
|
74
|
-
findById: (id: string) => Promise<User | undefined>;
|
|
75
|
-
};
|
|
76
|
-
|
|
77
|
-
export const buildUserRepository = (): UserRepository => ({
|
|
78
|
-
findById: async (id) => db.users.find(id),
|
|
79
|
-
});
|
|
80
|
-
```
|
|
81
|
-
|
|
82
|
-
```ts
|
|
83
|
-
// src/services/buildUserService.ts
|
|
84
|
-
import type { UserRepository } from "./buildUserRepository.js";
|
|
85
|
-
|
|
86
|
-
export type UserService = {
|
|
87
|
-
getUser: (id: string) => Promise<User | undefined>;
|
|
88
|
-
};
|
|
89
|
-
|
|
90
|
-
type UserServiceDeps = {
|
|
91
|
-
userRepository: UserRepository;
|
|
92
|
-
};
|
|
93
|
-
|
|
94
|
-
export const buildUserService = ({
|
|
95
|
-
userRepository,
|
|
96
|
-
}: UserServiceDeps): UserService => ({
|
|
97
|
-
getUser: (id) => userRepository.findById(id),
|
|
98
|
-
});
|
|
99
|
-
```
|
|
100
|
-
|
|
101
|
-
The named-deps-type pattern is required. Factories cannot destructure directly from `IocGeneratedCradle`, and inline object literals (`({ foo, bar }: { foo: Foo; bar: Bar })`) aren't allowed either — codegen will reject both. The rule is: the first parameter must be a named `interface` or `type` alias.
|
|
102
|
-
|
|
103
|
-
Three reasons:
|
|
104
|
-
|
|
105
|
-
1. **The cradle is generated from your factories' declarations.** A factory declaring its inputs by referencing the cradle would be a chicken-and-egg loop — the cradle doesn't exist yet at the moment codegen reads the factory.
|
|
106
|
-
|
|
107
|
-
2. **The deps type is the factory's testable contract.** Exporting `type UserServiceDeps = { ... }` means tests can `import type { UserServiceDeps }`, build a literal satisfying it, and call the factory directly with no container at all (see [Testing](#testing) below). Inline literals aren't importable — tests would have to reconstruct the same shape by hand in every file, and that drifts.
|
|
108
|
-
|
|
109
|
-
3. **The deps type is documentation.** When someone opens the file, the named declaration sits at the top and says exactly what the factory consumes. Inline literals bury the contract inside the function signature, where it competes for attention with parameter names and the return type.
|
|
110
|
-
|
|
111
|
-
The cost is one extra line per factory. That's the deal.
|
|
112
|
-
|
|
113
|
-
### 2. Configure
|
|
114
|
-
|
|
115
|
-
Create `ioc.config.ts` at your package root or under `src/`:
|
|
116
|
-
|
|
117
|
-
```ts
|
|
118
|
-
import { defineIocConfig } from "ioc-manifest";
|
|
119
|
-
|
|
120
|
-
export default defineIocConfig({
|
|
121
|
-
discovery: {
|
|
122
|
-
scanDirs: "src",
|
|
123
|
-
generatedDir: "generated",
|
|
124
|
-
},
|
|
125
|
-
});
|
|
126
|
-
```
|
|
127
|
-
|
|
128
|
-
That's the minimal config. The generator scans `src/` for `build*` exports and writes output to `generated/`.
|
|
129
|
-
|
|
130
|
-
### 3. Generate
|
|
131
|
-
|
|
132
5
|
```bash
|
|
133
|
-
|
|
134
|
-
```
|
|
135
|
-
|
|
136
|
-
Run this after changing factories or config. The generator prints a summary:
|
|
137
|
-
|
|
138
|
-
```
|
|
139
|
-
Generated generated/ioc-manifest.ts — 12 module factory(ies), 8 contract(s).
|
|
140
|
-
```
|
|
141
|
-
|
|
142
|
-
You can also call `generateManifest()` programmatically if you need to integrate generation into a custom build script.
|
|
143
|
-
|
|
144
|
-
### 4. Bootstrap Awilix
|
|
145
|
-
|
|
146
|
-
```ts
|
|
147
|
-
import { createContainer, InjectionMode } from "awilix";
|
|
148
|
-
import { registerIocFromManifest } from "ioc-manifest";
|
|
149
|
-
import { iocManifest } from "./generated/ioc-manifest.js";
|
|
150
|
-
import type { IocGeneratedCradle } from "./generated/ioc-registry.types.js";
|
|
151
|
-
|
|
152
|
-
const container = createContainer<IocGeneratedCradle>({
|
|
153
|
-
injectionMode: InjectionMode.PROXY,
|
|
154
|
-
});
|
|
155
|
-
|
|
156
|
-
registerIocFromManifest(container, [iocManifest]);
|
|
157
|
-
|
|
158
|
-
// Fully typed — no 'any', no string guessing
|
|
159
|
-
const userService = container.resolve("userService");
|
|
160
|
-
```
|
|
161
|
-
|
|
162
|
-
Note that `registerIocFromManifest` takes an **array** of manifests, even when there's only one. The array is set-like — ordering is irrelevant, and the same input always produces the same registrations.
|
|
163
|
-
|
|
164
|
-
That's all you need for most single-package applications. The sections below cover the conventions in more detail. For monorepo composition, see [Cross-package composition](#cross-package-composition).
|
|
165
|
-
|
|
166
|
-
---
|
|
167
|
-
|
|
168
|
-
## What gets generated
|
|
169
|
-
|
|
170
|
-
Here's what library-mode output looks like for a small app. You never edit these files — they're regenerated from source.
|
|
171
|
-
|
|
172
|
-
**`ioc-registry.types.ts`** — the typed cradle and externals:
|
|
173
|
-
|
|
174
|
-
```ts
|
|
175
|
-
/* AUTO-GENERATED. DO NOT EDIT. */
|
|
176
|
-
import type { Logger } from "../services/buildConsoleLogger.js";
|
|
177
|
-
import type { MediaStorage } from "../services/buildLocalMediaStorage.js";
|
|
178
|
-
import type { UserService } from "../services/buildUserService.js";
|
|
179
|
-
import type { Database } from "../types/Database.js";
|
|
180
|
-
|
|
181
|
-
export interface IocGeneratedCradle {
|
|
182
|
-
logger: Logger;
|
|
183
|
-
mediaStorage: MediaStorage;
|
|
184
|
-
mediaStorages: ReadonlyArray<MediaStorage>;
|
|
185
|
-
userService: UserService;
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
export interface IocExternals {
|
|
189
|
-
database: Database;
|
|
190
|
-
}
|
|
191
|
-
```
|
|
192
|
-
|
|
193
|
-
`mediaStorages` (plural) appears automatically because there are multiple `MediaStorage` implementations.
|
|
194
|
-
|
|
195
|
-
`IocExternals` lists every dependency the package consumes from outside — keys destructured by factory deps types where no local factory supplies them. `IocGeneratedCradle` contains only what the package itself supplies. The two interfaces together describe the package's full contract: what it provides and what it needs.
|
|
196
|
-
|
|
197
|
-
When a package declares `scopeProvided`, those keys are emitted into a separate `IocScopeProvided` interface rather than `IocExternals`, with a JSDoc banner reminding you to register them onto a child scope:
|
|
198
|
-
|
|
199
|
-
```ts
|
|
200
|
-
export interface IocScopeProvided {
|
|
201
|
-
viewerId: string;
|
|
202
|
-
}
|
|
203
|
-
```
|
|
204
|
-
|
|
205
|
-
The main manifest file also exports `IOC_SCOPE_PROVIDED_KEYS` (a `readonly` string tuple) so app code can reference the set — for example, to assert a request-scope helper covers the keys the current path resolves. See [`scopeProvided`](#scopeprovided).
|
|
206
|
-
|
|
207
|
-
**`ioc-manifest.ts`** — the registration data:
|
|
208
|
-
|
|
209
|
-
```ts
|
|
210
|
-
/* AUTO-GENERATED. DO NOT EDIT. */
|
|
211
|
-
import type {
|
|
212
|
-
IocGeneratedContainerManifest,
|
|
213
|
-
IocModuleNamespace,
|
|
214
|
-
} from "ioc-manifest";
|
|
215
|
-
|
|
216
|
-
import * as ioc_services_buildConsoleLogger from "../services/buildConsoleLogger.js";
|
|
217
|
-
import * as ioc_services_buildLocalMediaStorage from "../services/buildLocalMediaStorage.js";
|
|
218
|
-
// ... more imports ...
|
|
219
|
-
|
|
220
|
-
export const iocManifest = {
|
|
221
|
-
manifestSchemaVersion: 2,
|
|
222
|
-
moduleImports: [
|
|
223
|
-
/* ... */
|
|
224
|
-
] as const satisfies readonly IocModuleNamespace[],
|
|
225
|
-
contracts: {
|
|
226
|
-
Logger: {
|
|
227
|
-
consoleLogger: {
|
|
228
|
-
exportName: "buildConsoleLogger",
|
|
229
|
-
registrationKey: "consoleLogger",
|
|
230
|
-
contractName: "Logger",
|
|
231
|
-
implementationName: "consoleLogger",
|
|
232
|
-
lifetime: "singleton",
|
|
233
|
-
moduleIndex: 0,
|
|
234
|
-
default: true,
|
|
235
|
-
discoveredBy: "naming",
|
|
236
|
-
},
|
|
237
|
-
},
|
|
238
|
-
// ... more contracts ...
|
|
239
|
-
},
|
|
240
|
-
} as const satisfies IocGeneratedContainerManifest;
|
|
241
|
-
```
|
|
242
|
-
|
|
243
|
-
---
|
|
244
|
-
|
|
245
|
-
## How conventions work
|
|
246
|
-
|
|
247
|
-
### Factory discovery
|
|
248
|
-
|
|
249
|
-
The generator looks for exported functions whose name starts with `build` (configurable via `factoryPrefix`). For `buildHttpClient`:
|
|
250
|
-
|
|
251
|
-
| Concept | Derived value |
|
|
252
|
-
| ----------------------- | ----------------------------------------------------- |
|
|
253
|
-
| **Contract** | The return type's symbol name, e.g. `HttpClient` |
|
|
254
|
-
| **Implementation name** | Strip prefix, lowercase first char → `httpClient` |
|
|
255
|
-
| **Registration key** | Same as implementation name by default → `httpClient` |
|
|
256
|
-
| **Default access key** | Camel-cased contract name → `httpClient` |
|
|
257
|
-
|
|
258
|
-
The contract type must be a named type (interface or type alias) that is imported or declared in the factory's file. Anonymous object literals, primitives, and union types are skipped.
|
|
259
|
-
|
|
260
|
-
### Default implementation selection
|
|
261
|
-
|
|
262
|
-
When a contract has only one implementation, it is the default. When there are multiple, the default is selected by this precedence:
|
|
263
|
-
|
|
264
|
-
1. **App override** — `default: true` in an app-mode `ioc.config` (highest precedence; only relevant when composing)
|
|
265
|
-
2. **Explicit** — `default: true` on exactly one implementation in the local `ioc.config`
|
|
266
|
-
3. **Convention** — the implementation whose registration key equals the camel-cased contract name (e.g. `mediaStorage` for `MediaStorage`)
|
|
267
|
-
4. **Single** — if only one implementation exists, it's the default
|
|
268
|
-
|
|
269
|
-
If the choice is ambiguous, generation fails with a clear error telling you what to do.
|
|
270
|
-
|
|
271
|
-
### Automatic collections
|
|
272
|
-
|
|
273
|
-
When a contract has more than one implementation, a plural collection key is auto-registered. `MediaStorage` with implementations `localMediaStorage` and `s3MediaStorage` gives you:
|
|
274
|
-
|
|
275
|
-
- `container.resolve("mediaStorage")` → the default `MediaStorage`
|
|
276
|
-
- `container.resolve("localMediaStorage")` → the local implementation
|
|
277
|
-
- `container.resolve("s3MediaStorage")` → the S3 implementation
|
|
278
|
-
- `container.resolve("mediaStorages")` → `ReadonlyArray<MediaStorage>` with all implementations
|
|
279
|
-
|
|
280
|
-
Pluralization handles common English patterns (`Service` → `services`, `Factory` → `factories`, `Cache` → `caches`).
|
|
281
|
-
|
|
282
|
-
This is the same fundamental idea behind having multiple implementations of a single interface in any IoC container: you can swap implementations by environment. Have one `ioc.config` for production that points to real services, a different one for development that uses local stubs, and a third for testing that wires in mocks — without touching any factory source code. The config is the only thing that changes.
|
|
283
|
-
|
|
284
|
-
### Dependency inference
|
|
285
|
-
|
|
286
|
-
The generator analyzes each factory's first parameter — the named deps type — to determine which keys the factory consumes. Every property in the deps type becomes a **demand**. If a demanded key has a corresponding `build*` factory in the same package, it's a local dependency. If not, it's an external (and appears in `IocExternals`).
|
|
287
|
-
|
|
288
|
-
Codegen validates type agreement across factories: if `buildA` declares `database: Knex` and `buildB` declares `database: PostgresClient`, codegen fails with both locations and the conflicting types named.
|
|
289
|
-
|
|
290
|
-
---
|
|
291
|
-
|
|
292
|
-
## `ioc.config.ts` — single source of policy
|
|
293
|
-
|
|
294
|
-
All registration policy lives in one file. Factory source files stay plain — no decorators, no metadata objects, no `RESOLVER` symbols.
|
|
295
|
-
|
|
296
|
-
```ts
|
|
297
|
-
import { defineIocConfig } from "ioc-manifest";
|
|
298
|
-
|
|
299
|
-
export default defineIocConfig({
|
|
300
|
-
discovery: {
|
|
301
|
-
/* where to scan */
|
|
302
|
-
},
|
|
303
|
-
registrations: {
|
|
304
|
-
/* overrides per contract/implementation */
|
|
305
|
-
},
|
|
306
|
-
groups: {
|
|
307
|
-
/* cross-contract grouping by base type (advanced) */
|
|
308
|
-
},
|
|
309
|
-
// app mode only:
|
|
310
|
-
composedManifests: [
|
|
311
|
-
/* package names to compose */
|
|
312
|
-
],
|
|
313
|
-
});
|
|
314
|
-
```
|
|
315
|
-
|
|
316
|
-
### `discovery`
|
|
317
|
-
|
|
318
|
-
| Field | Purpose | Default |
|
|
319
|
-
| --------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------ |
|
|
320
|
-
| `scanDirs` | **Required.** Directories to scan. String, string array, or array of `{ path, scope? }` objects. Paths must resolve within the package root. | — |
|
|
321
|
-
| `includes` | Glob patterns for files to include. | `["**/*.{ts,tsx,js,mjs,cjs}"]` |
|
|
322
|
-
| `excludes` | Glob patterns for files to exclude. | `["**/*.d.ts", "**/*.test.ts", ...]` |
|
|
323
|
-
| `factoryPrefix` | Export name prefix for factory discovery. | `"build"` |
|
|
324
|
-
| `generatedDir` | Output directory for generated files. | `"generated"` |
|
|
325
|
-
|
|
326
|
-
### `registrations`
|
|
327
|
-
|
|
328
|
-
Override defaults, lifetimes, and keys per contract and implementation.
|
|
329
|
-
|
|
330
|
-
```ts
|
|
331
|
-
registrations: {
|
|
332
|
-
MediaStorage: {
|
|
333
|
-
s3MediaStorage: { default: true, lifetime: "singleton" },
|
|
334
|
-
localMediaStorage: { lifetime: "transient" },
|
|
335
|
-
},
|
|
336
|
-
Knex: {
|
|
337
|
-
$contract: { accessKey: "database" },
|
|
338
|
-
pg: { default: true, lifetime: "singleton" },
|
|
339
|
-
},
|
|
340
|
-
},
|
|
341
|
-
```
|
|
342
|
-
|
|
343
|
-
Under each contract name, keys are implementation names from discovery (`buildFoo` → `foo`). The reserved `$contract` key holds contract-level options.
|
|
344
|
-
|
|
345
|
-
| Per-implementation field | Effect |
|
|
346
|
-
| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
347
|
-
| `name` | Overrides the Awilix registration key |
|
|
348
|
-
| `lifetime` | `"singleton"` \| `"scoped"` \| `"transient"` |
|
|
349
|
-
| `default` | `true` to select this implementation as the contract default |
|
|
350
|
-
| `source` | (app mode only) Resolve same-key conflicts across composed manifests. See [Cross-package composition](#cross-package-composition). |
|
|
351
|
-
| `allowLifetimeInversion` | Opt out of the lifetime-inversion check for this implementation. `true` allows all shorter-lived dependencies; a `string[]` allows only the listed demanded keys. See [Lifetime inversion checks](#lifetime-inversion-checks). |
|
|
352
|
-
|
|
353
|
-
| `$contract` field | Effect |
|
|
354
|
-
| ----------------- | ----------------------------------------------------------------------------------------------- |
|
|
355
|
-
| `accessKey` | Overrides the cradle property name for the default slot (e.g. `"database"` instead of `"knex"`) |
|
|
356
|
-
|
|
357
|
-
### `lifetimeMarkers`
|
|
358
|
-
|
|
359
|
-
Declare marker interfaces that map to Awilix lifetimes. Any factory whose return type **declares** heritage to a marker (via `extends` or a type-alias `&` intersection) inherits that lifetime automatically.
|
|
360
|
-
|
|
361
|
-
```ts
|
|
362
|
-
lifetimeMarkers: {
|
|
363
|
-
IScoped: "scoped",
|
|
364
|
-
ITransient: "transient",
|
|
365
|
-
},
|
|
366
|
-
```
|
|
367
|
-
|
|
368
|
-
Keys are interface or type-alias names visible in the package's TypeScript program at codegen. Values are `singleton`, `scoped`, or `transient`. An empty object `{}` skips marker analysis.
|
|
369
|
-
|
|
370
|
-
Markers match by **declared inheritance**, not structural shape. Use `extends IScoped` on service or contract interfaces (or `type Foo = Bar & IScoped`). Empty marker interfaces are fine. See [Lifetime markers](#lifetime-markers).
|
|
371
|
-
|
|
372
|
-
**Lifetime precedence** (highest first):
|
|
373
|
-
|
|
374
|
-
1. `registrations[Contract][implementation].lifetime`
|
|
375
|
-
2. Lifetime marker on return type (`lifetimeMarkers`)
|
|
376
|
-
3. `discovery.scanDirs[].scope` (folder-scoped default)
|
|
377
|
-
4. Default: `singleton`
|
|
378
|
-
|
|
379
|
-
### `scopeProvided`
|
|
380
|
-
|
|
381
|
-
Some dependencies aren't built by any factory and never can be — they're **runtime values registered onto a child scope per unit of work**. The canonical case is a request: a `viewerId`, `tenantId`, or `requestId` known only when the request arrives, registered onto a per-request child scope and consumed by services resolved within it.
|
|
382
|
-
|
|
383
|
-
```ts
|
|
384
|
-
scopeProvided: ["viewerId", "publicLinkId"],
|
|
385
|
-
```
|
|
386
|
-
|
|
387
|
-
A factory destructures `viewerId` like any other dependency. No local factory supplies it, so without this declaration it'd be classified as an external and the composition's externals check would demand that _something build it_ — which nothing can. `scopeProvided` tells the generator the key is satisfied at runtime by scope registration, not by a factory.
|
|
388
|
-
|
|
389
|
-
Declared keys are emitted into a dedicated `IocScopeProvided` interface (instead of `IocExternals`) and excluded from the externals-satisfaction check. At runtime you register them yourself, onto the child scope, before resolving anything that depends on them:
|
|
390
|
-
|
|
391
|
-
```ts
|
|
392
|
-
const scope = container.createScope();
|
|
393
|
-
scope.register({ viewerId: asValue(user.id) });
|
|
394
|
-
const reader = scope.resolve("viewerAlbumReadService"); // works
|
|
395
|
-
```
|
|
396
|
-
|
|
397
|
-
**The contract is enforced at runtime, not compile time — by design.** Composition cannot verify that a runtime value will be registered; only the running container can. So if you resolve a scope-provided service from the root container, or from a scope that forgot to register the value, Awilix throws an `IocResolutionError` at resolution. It never returns a placeholder. That throw _is_ the safety guarantee — a scoped service can't silently resolve outside its scope.
|
|
398
|
-
|
|
399
|
-
**Composing without resolving needs no provision.** A package that composes a manifest containing scope-provided services but never resolves them — a background worker pulling jobs, say — provides nothing and inherits no obligation. The keys leave `IocExternals`, so the worker's composition is satisfied without it touching `viewerId` at all. You declare `scopeProvided` once, in the package that _demands_ the key; every consumer inherits the exemption.
|
|
400
|
-
|
|
401
|
-
**Generation-time guards:**
|
|
402
|
-
|
|
403
|
-
- Declaring a key that no factory demands → warning (`[ioc-config]`), usually a typo.
|
|
404
|
-
- Declaring a key that a local factory also builds → error. A key can't be both manifest-built and scope-provided.
|
|
405
|
-
|
|
406
|
-
This is distinct from the `scoped` **lifetime**: a scoped-lifetime service is _instantiated_ once per scope; a scope-provided _value_ is _injected_ into the scope at runtime. The two are independent — a service can be one without the other.
|
|
407
|
-
|
|
408
|
-
### App-mode fields
|
|
409
|
-
|
|
410
|
-
These only apply in app mode (a package that composes manifests from other packages):
|
|
411
|
-
|
|
412
|
-
| Field | Purpose |
|
|
413
|
-
| ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
414
|
-
| `composedManifests` | Array of package names whose manifests this app composes. Setting this turns on app mode. |
|
|
415
|
-
| `packageName` | The local package's npm name. Used for self-reference detection. Falls back to `package.json` `name`; required if neither is available. |
|
|
416
|
-
| `groupBaseTypeAliases` | Equivalence sets for canonical base type identifiers when hoisting produces mismatches. See [Cross-package composition](#cross-package-composition). |
|
|
417
|
-
|
|
418
|
-
| Library-mode-only field | Purpose |
|
|
419
|
-
| ----------------------- | --------------------------------------------------------------------------------------------------------------------- |
|
|
420
|
-
| `manifestExportPath` | Informational. The path your `package.json` `exports` points at for the manifest. Default `./generated/ioc-manifest`. |
|
|
421
|
-
|
|
422
|
-
`composedManifests` and `manifestExportPath` are mutually exclusive — a config is either library or app mode.
|
|
423
|
-
|
|
424
|
-
---
|
|
425
|
-
|
|
426
|
-
## Cross-package composition
|
|
427
|
-
|
|
428
|
-
Once you have more than one package in a monorepo, you typically have one or more apps that compose manifests from shared libraries. This is what app mode is for.
|
|
429
|
-
|
|
430
|
-
### The model
|
|
431
|
-
|
|
432
|
-
Each package generates its own manifest in library mode, scanning only its own source. The app's config declares which packages it composes with via `composedManifests`. Codegen produces an extra file in the app — `ioc-composed.ts` — that imports each package's manifest, intersects their cradle types, and emits compile-time assertions that every composed package's externals are satisfied.
|
|
433
|
-
|
|
434
|
-
At runtime, the app passes the composed manifests array to `registerIocFromManifest`. Composition is set-like: ordering doesn't matter. Conflicts (two manifests supplying the same registration key) are hard errors by default, resolved via explicit `source` config.
|
|
435
|
-
|
|
436
|
-
### A monorepo example
|
|
437
|
-
|
|
438
|
-
```
|
|
439
|
-
packages/
|
|
440
|
-
lib-storage/ # library mode
|
|
441
|
-
src/
|
|
442
|
-
ioc.config.ts
|
|
443
|
-
factories/
|
|
444
|
-
types/
|
|
445
|
-
lib-services/ # library mode
|
|
446
|
-
src/
|
|
447
|
-
ioc.config.ts
|
|
448
|
-
factories/
|
|
449
|
-
types/
|
|
450
|
-
app/ # app mode
|
|
451
|
-
src/
|
|
452
|
-
ioc.config.ts
|
|
453
|
-
bootstrap.ts
|
|
454
|
-
factories/
|
|
455
|
-
```
|
|
456
|
-
|
|
457
|
-
`lib-storage` registers `Storage` implementations. `lib-services` registers services that consume `Storage` (declared in their deps types — so `storage` appears in `lib-services`'s `IocExternals`). The app composes both and supplies anything neither library supplies.
|
|
458
|
-
|
|
459
|
-
### App config
|
|
460
|
-
|
|
461
|
-
```ts
|
|
462
|
-
// packages/app/src/ioc.config.ts
|
|
463
|
-
import { defineIocConfig } from "ioc-manifest";
|
|
464
|
-
|
|
465
|
-
export default defineIocConfig({
|
|
466
|
-
discovery: {
|
|
467
|
-
scanDirs: "src",
|
|
468
|
-
generatedDir: "generated",
|
|
469
|
-
},
|
|
470
|
-
composedManifests: ["@example/lib-storage", "@example/lib-services"],
|
|
471
|
-
registrations: {
|
|
472
|
-
Storage: {
|
|
473
|
-
s3Storage: { default: true },
|
|
474
|
-
},
|
|
475
|
-
},
|
|
476
|
-
});
|
|
477
|
-
```
|
|
478
|
-
|
|
479
|
-
### Required package exports
|
|
480
|
-
|
|
481
|
-
Each composed package's `package.json` must expose two subpath exports:
|
|
482
|
-
|
|
483
|
-
```jsonc
|
|
484
|
-
{
|
|
485
|
-
"exports": {
|
|
486
|
-
".": "./src/index.ts",
|
|
487
|
-
"./iocManifest": "./src/generated/ioc-manifest.js",
|
|
488
|
-
"./iocTypes": "./src/generated/ioc-registry.types.js",
|
|
489
|
-
},
|
|
490
|
-
}
|
|
491
|
-
```
|
|
492
|
-
|
|
493
|
-
(Substitute `./dist/...` for published packages with a build step.)
|
|
494
|
-
|
|
495
|
-
### Generated `ioc-composed.ts`
|
|
496
|
-
|
|
497
|
-
```ts
|
|
498
|
-
/* AUTO-GENERATED. DO NOT EDIT. */
|
|
499
|
-
import { iocManifest as localManifest } from "./ioc-manifest.js";
|
|
500
|
-
import { iocManifest as libStorageManifest } from "@example/lib-storage/iocManifest";
|
|
501
|
-
import { iocManifest as libServicesManifest } from "@example/lib-services/iocManifest";
|
|
502
|
-
|
|
503
|
-
import type { IocGeneratedCradle as LocalCradle } from "./ioc-registry.types.js";
|
|
504
|
-
import type { IocGeneratedCradle as LibStorageCradle } from "@example/lib-storage/iocTypes";
|
|
505
|
-
import type { IocGeneratedCradle as LibServicesCradle } from "@example/lib-services/iocTypes";
|
|
506
|
-
import type { IocExternals as LibStorageExternals } from "@example/lib-storage/iocTypes";
|
|
507
|
-
import type { IocExternals as LibServicesExternals } from "@example/lib-services/iocTypes";
|
|
508
|
-
|
|
509
|
-
export const composedManifests = [
|
|
510
|
-
localManifest,
|
|
511
|
-
libStorageManifest,
|
|
512
|
-
libServicesManifest,
|
|
513
|
-
] as const;
|
|
514
|
-
|
|
515
|
-
export type AppCradle = LocalCradle & LibStorageCradle & LibServicesCradle;
|
|
516
|
-
|
|
517
|
-
// Compile-time externals satisfaction assertions
|
|
518
|
-
type _IocExpect<T extends true> = T;
|
|
519
|
-
type _LibStorageExternalsSatisfied =
|
|
520
|
-
LibStorageExternals extends Pick<AppCradle, keyof LibStorageExternals>
|
|
521
|
-
? true
|
|
522
|
-
: false;
|
|
523
|
-
type _LibStorageExternalsAssert = _IocExpect<_LibStorageExternalsSatisfied>;
|
|
524
|
-
type _LibServicesExternalsSatisfied =
|
|
525
|
-
LibServicesExternals extends Pick<AppCradle, keyof LibServicesExternals>
|
|
526
|
-
? true
|
|
527
|
-
: false;
|
|
528
|
-
type _LibServicesExternalsAssert = _IocExpect<_LibServicesExternalsSatisfied>;
|
|
529
|
-
|
|
530
|
-
export const composedRegistrationOverrides = {
|
|
531
|
-
/* ... */
|
|
532
|
-
};
|
|
533
|
-
```
|
|
534
|
-
|
|
535
|
-
If `lib-services` requires a `logger` and no manifest in the composition supplies it, `_LibServicesExternalsAssert` fails compilation with a TypeScript error pointing at the assertion line. You don't have to run anything to find out you forgot something.
|
|
536
|
-
|
|
537
|
-
### App bootstrap
|
|
538
|
-
|
|
539
|
-
```ts
|
|
540
|
-
import { createContainer } from "awilix";
|
|
541
|
-
import { registerIocFromManifest } from "ioc-manifest";
|
|
542
|
-
import {
|
|
543
|
-
composedManifests,
|
|
544
|
-
composedRegistrationOverrides,
|
|
545
|
-
type AppCradle,
|
|
546
|
-
} from "./generated/ioc-composed.js";
|
|
547
|
-
|
|
548
|
-
const container = createContainer<AppCradle>();
|
|
549
|
-
registerIocFromManifest(
|
|
550
|
-
container,
|
|
551
|
-
composedManifests,
|
|
552
|
-
composedRegistrationOverrides,
|
|
553
|
-
);
|
|
554
|
-
|
|
555
|
-
const uploadService = container.resolve("uploadService");
|
|
556
|
-
```
|
|
557
|
-
|
|
558
|
-
### Resolving same-key conflicts
|
|
559
|
-
|
|
560
|
-
If two composed manifests both supply the same Awilix registration key, composition fails with a hard error naming both manifests. Resolve via the `source` field:
|
|
561
|
-
|
|
562
|
-
```ts
|
|
563
|
-
registrations: {
|
|
564
|
-
AlbumRepository: {
|
|
565
|
-
albumRepository: { source: "local" }, // or "@example/lib-services"
|
|
566
|
-
},
|
|
567
|
-
}
|
|
568
|
-
```
|
|
569
|
-
|
|
570
|
-
`source: "local"` picks the app's own factory; a package name picks that package's registration. There's no last-write-wins or array-position semantics — you decide explicitly which manifest's registration wins.
|
|
571
|
-
|
|
572
|
-
### Groups across manifests
|
|
573
|
-
|
|
574
|
-
If multiple composed packages declare contributors to the same group (e.g. several packages register `DiscountStrategy` implementations and all declare a `discountStrategies` collection group), the group merges across manifests. `container.resolve("discountStrategies")` returns the union.
|
|
575
|
-
|
|
576
|
-
For this to work, all contributors must reference the same canonical base type — typically by importing it from a shared contracts package. If npm hoisting produces a single physical file for the base type, identity matching works automatically.
|
|
577
|
-
|
|
578
|
-
In rare cases (version skew, peer-dep conflicts, nested installs) two contributors may end up with different physical paths for what is structurally the same type. The library reports this with a clear error and a remediation hint, including the exact config block to paste:
|
|
579
|
-
|
|
580
|
-
```ts
|
|
581
|
-
// in the app's ioc.config.ts
|
|
582
|
-
groupBaseTypeAliases: {
|
|
583
|
-
discountStrategies: [
|
|
584
|
-
"/path/to/a.ts:DiscountStrategy",
|
|
585
|
-
"/path/to/b.ts:DiscountStrategy",
|
|
586
|
-
],
|
|
587
|
-
}
|
|
588
|
-
```
|
|
589
|
-
|
|
590
|
-
The library treats the listed identifiers as equivalent. This is an escape hatch, not a normal-path mechanism.
|
|
591
|
-
|
|
592
|
-
---
|
|
593
|
-
|
|
594
|
-
## Dev and production builds
|
|
595
|
-
|
|
596
|
-
Getting an IoC container to work across development (loose TypeScript source files) and production (a bundled single-file build) is a real headache. `ioc-manifest` handles both cases because the generated manifest uses static `import * as ...` statements rather than runtime filesystem scanning.
|
|
597
|
-
|
|
598
|
-
In **development**, the generator discovers factories from your TypeScript source tree and emits relative imports that point to your `.ts` files (via `.js` extensions for ESM). Everything resolves naturally through your dev toolchain (tsx, ts-node, etc.).
|
|
599
|
-
|
|
600
|
-
In **production**, if you bundle your app into a single file (esbuild, rollup, etc.), those same static imports get resolved and inlined by the bundler. The manifest doesn't do any filesystem scanning at runtime — it's just a data structure with pre-resolved imports. Your bundler treats it like any other module graph.
|
|
601
|
-
|
|
602
|
-
This was a deliberate design choice (and a painful one to get right). There's no `loadModules` glob at runtime, no dynamic `require`, no filesystem walking. The generated manifest is a plain TypeScript module that any bundler can tree-shake and inline.
|
|
603
|
-
|
|
604
|
-
---
|
|
605
|
-
|
|
606
|
-
## CLI: `ioc`
|
|
607
|
-
|
|
608
|
-
```bash
|
|
609
|
-
npx ioc # prints help
|
|
610
|
-
npx ioc generate # discover factories, emit manifest + types (and ioc-composed.ts in app mode)
|
|
611
|
-
npx ioc generate -c ./ioc.config.test.ts # generate with a specific config
|
|
612
|
-
npx ioc inspect # loads the generated manifest and prints a summary
|
|
613
|
-
npx ioc inspect --discovery # re-runs discovery without reading the manifest
|
|
614
|
-
npx ioc validate # app mode: cross-manifest checks against composedManifests
|
|
615
|
-
npx ioc validate --json # machine-readable issue list
|
|
616
|
-
```
|
|
617
|
-
|
|
618
|
-
| Flag | Purpose |
|
|
619
|
-
| -------------------------- | --------------------------------------------------------------------------------------- |
|
|
620
|
-
| `--discovery` | (inspect only) Re-run factory discovery and planning; don't read the generated manifest |
|
|
621
|
-
| `--json` | (validate only) Emit issues as JSON |
|
|
622
|
-
| `--config PATH`, `-c PATH` | Explicit path to `ioc.config.ts` |
|
|
623
|
-
| `--project PATH` | Project directory for config resolution (default: cwd) |
|
|
624
|
-
|
|
625
|
-
Set `IOC_DEBUG=1` for full stack traces on errors.
|
|
626
|
-
|
|
627
|
-
### `ioc validate`
|
|
628
|
-
|
|
629
|
-
A separate command from `generate` because they have different audiences. `generate` runs frequently during development and shouldn't fail on transient inconsistencies (a sibling package mid-refactor). `validate` is the pre-merge / pre-deploy gate.
|
|
630
|
-
|
|
631
|
-
`validate` loads every composed manifest, runs every cross-manifest check at once, and reports all issues — not just the first. It does not modify any files; pure inspection. Exit code is non-zero if any error-severity issue is reported.
|
|
632
|
-
|
|
633
|
-
Typical output for a failing run:
|
|
634
|
-
|
|
635
|
-
```
|
|
636
|
-
[app-config] registrations references unknown contract "Storge"
|
|
637
|
-
Known local contracts: Logger.
|
|
638
|
-
Known composed contracts: Logger, LoggingService, Storage, UploadService.
|
|
639
|
-
Did you mean: "Storage"?
|
|
640
|
-
Suggested fix: Fix the contract name in ioc.config.ts registrations, or add a factory for "Storge".
|
|
641
|
-
|
|
642
|
-
Validation failed: 1 error, 0 warnings.
|
|
643
|
-
```
|
|
644
|
-
|
|
645
|
-
Library-mode invocations print an informational message and exit 0 — there's nothing cross-manifest to validate.
|
|
646
|
-
|
|
647
|
-
Recommended workflow: `ioc generate` → `ioc validate` → `tsc --noEmit` → deploy.
|
|
648
|
-
|
|
649
|
-
---
|
|
650
|
-
|
|
651
|
-
## Testing
|
|
652
|
-
|
|
653
|
-
The named-deps-type pattern at the factory site enables three levels of testing, each with the ergonomics that fit:
|
|
654
|
-
|
|
655
|
-
### Factory-level (no container)
|
|
656
|
-
|
|
657
|
-
Most unit tests don't need a container. Import the factory, import its deps type, hand-build a stub, call the factory:
|
|
658
|
-
|
|
659
|
-
```ts
|
|
660
|
-
import { buildValidateOperationService } from "../src/...";
|
|
661
|
-
import type { ValidateOperationServiceDeps } from "../src/...";
|
|
662
|
-
|
|
663
|
-
const deps: ValidateOperationServiceDeps = {
|
|
664
|
-
mediaItemReadRepository: {
|
|
665
|
-
/* stub */
|
|
666
|
-
},
|
|
667
|
-
grantReadRepository: {
|
|
668
|
-
/* stub */
|
|
669
|
-
},
|
|
670
|
-
albumMemberReadRepository: {
|
|
671
|
-
/* stub */
|
|
672
|
-
},
|
|
673
|
-
};
|
|
674
|
-
const svc = buildValidateOperationService(deps);
|
|
675
|
-
```
|
|
676
|
-
|
|
677
|
-
No container, no manifest, no awilix. TypeScript enforces what must be provided.
|
|
678
|
-
|
|
679
|
-
### Container-level with mocked externals
|
|
680
|
-
|
|
681
|
-
When you want the full container — testing wiring, lifetimes, multi-service interactions inside the package — register the package's manifest then fill `IocExternals` with `asValue` stubs:
|
|
682
|
-
|
|
683
|
-
```ts
|
|
684
|
-
import { createContainer, asValue } from "awilix";
|
|
685
|
-
import { registerIocFromManifest } from "ioc-manifest";
|
|
686
|
-
import { iocManifest } from "../src/generated/ioc-manifest.js";
|
|
687
|
-
import type {
|
|
688
|
-
IocGeneratedCradle,
|
|
689
|
-
IocExternals,
|
|
690
|
-
} from "../src/generated/ioc-registry.types.js";
|
|
691
|
-
|
|
692
|
-
const container = createContainer<IocGeneratedCradle>();
|
|
693
|
-
registerIocFromManifest(container, [iocManifest]);
|
|
694
|
-
|
|
695
|
-
const externals: IocExternals = {
|
|
696
|
-
database: mockKnex,
|
|
697
|
-
logger: silentLogger,
|
|
698
|
-
};
|
|
699
|
-
for (const [k, v] of Object.entries(externals)) {
|
|
700
|
-
container.register({ [k]: asValue(v) });
|
|
701
|
-
}
|
|
702
|
-
```
|
|
703
|
-
|
|
704
|
-
The `IocExternals` type makes the external surface a typed checklist: forget one and TypeScript errors; add a new external dep in the package and every test breaks until updated.
|
|
705
|
-
|
|
706
|
-
### Test-specific manifest
|
|
707
|
-
|
|
708
|
-
For shared stubs across many tests, write stub factories under `tests/stubs/` and a separate `ioc.config.test.ts` scanning both `src` and `tests/stubs`. Generate a test manifest. Use as above. Run with `npx ioc generate -c ./ioc.config.test.ts`.
|
|
709
|
-
|
|
710
|
-
---
|
|
711
|
-
|
|
712
|
-
## Error handling
|
|
713
|
-
|
|
714
|
-
Errors are designed to tell you exactly what went wrong and what to do about it.
|
|
715
|
-
|
|
716
|
-
**Config errors** are prefixed `[ioc-config]` — unknown contracts in `registrations`, duplicate defaults, key collisions. These fail at generation time before any files are written.
|
|
717
|
-
|
|
718
|
-
**Discovery errors** are prefixed `[ioc]` — duplicate registration keys, unresolvable contract types, overlapping scan directories with conflicting scopes, factories destructuring directly from `IocGeneratedCradle` (use named deps types instead).
|
|
719
|
-
|
|
720
|
-
**Validation errors** are prefixed by category (`[externals]`, `[same-key-conflict]`, `[group-base-type]`, etc.) and emitted by `ioc validate`. Validate aggregates: a failing run reports every issue at once, not just the first.
|
|
721
|
-
|
|
722
|
-
**Runtime resolution errors** use `IocResolutionError` with structured dependency chains:
|
|
723
|
-
|
|
724
|
-
```
|
|
725
|
-
[ioc] Cannot build AlbumService using implementation albumService.
|
|
726
|
-
|
|
727
|
-
Resolution chain:
|
|
728
|
-
AlbumService (albumService) [services/buildAlbumService.ts]
|
|
729
|
-
-> MediaStorage (s3MediaStorage) [services/buildS3MediaStorage.ts]
|
|
730
|
-
-> S3Client ✖ no registered implementation
|
|
731
|
-
```
|
|
732
|
-
|
|
733
|
-
Missing dependencies, cyclic references, lifetime violations, and factory exceptions are all caught and reported with the full resolution path.
|
|
734
|
-
|
|
735
|
-
A missing **scope-provided** value surfaces here too: resolving a service whose scope value wasn't registered produces a `no registered implementation` leaf for that key. If you see this for a key declared in `scopeProvided`, the fix is to register it onto the child scope before resolving — not to add a factory.
|
|
736
|
-
|
|
737
|
-
---
|
|
738
|
-
|
|
739
|
-
## Advanced usage
|
|
740
|
-
|
|
741
|
-
The basics — factory discovery, typed cradle, automatic collections — cover most applications. The features below are things you can reach for when your app grows or your architecture demands more structure.
|
|
742
|
-
|
|
743
|
-
### Lifetime markers
|
|
744
|
-
|
|
745
|
-
When services are organized by domain (`src/users/`, `src/orders/`) rather than by lifetime category, folder-scoped lifetimes fit poorly. **Lifetime markers** express cross-cutting lifetime policy via marker interfaces — the same **nominal** membership rules groups use (declared `extends`, not structural assignability).
|
|
746
|
-
|
|
747
|
-
#### Defining a marker
|
|
748
|
-
|
|
749
|
-
A marker is typically an empty interface (or a type alias you intersect with). Selective matching comes from **where you attach `extends`**, not from branding:
|
|
750
|
-
|
|
751
|
-
```ts
|
|
752
|
-
// shared types
|
|
753
|
-
export interface IScoped {}
|
|
754
|
-
|
|
755
|
-
export interface ITransient {}
|
|
756
|
-
```
|
|
757
|
-
|
|
758
|
-
#### Declaring markers
|
|
759
|
-
|
|
760
|
-
Map marker types to lifetimes in `ioc.config`:
|
|
761
|
-
|
|
762
|
-
```ts
|
|
763
|
-
lifetimeMarkers: {
|
|
764
|
-
IScoped: "scoped",
|
|
765
|
-
ITransient: "transient",
|
|
766
|
-
},
|
|
767
|
-
```
|
|
768
|
-
|
|
769
|
-
Keys are interface or type-alias names visible in the package's TypeScript program at codegen. Values are `singleton`, `scoped`, or `transient`. An empty object `{}` skips marker analysis.
|
|
770
|
-
|
|
771
|
-
#### Attaching markers to factories
|
|
772
|
-
|
|
773
|
-
Three attachment points, in order of locality. The pattern that fits your code best is usually the right one.
|
|
774
|
-
|
|
775
|
-
**Directly on the implementation type:**
|
|
776
|
-
|
|
777
|
-
```ts
|
|
778
|
-
export interface RequestTracingLogger extends LoggingService, IScoped {
|
|
779
|
-
ping: () => string;
|
|
780
|
-
}
|
|
781
|
-
```
|
|
782
|
-
|
|
783
|
-
**On a shared contract** (every implementation of `LoggingService` becomes scoped):
|
|
784
|
-
|
|
785
|
-
```ts
|
|
786
|
-
export interface LoggingService extends IScoped {
|
|
787
|
-
log: (msg: string) => void;
|
|
788
|
-
}
|
|
789
|
-
```
|
|
790
|
-
|
|
791
|
-
**On a group base type** — the cleanest pattern when implementations are already collected as a group. Every implementation in the group inherits the lifetime automatically:
|
|
792
|
-
|
|
793
|
-
```ts
|
|
794
|
-
export interface DiscountStrategy extends IScoped {
|
|
795
|
-
applies: (order: Order) => boolean;
|
|
796
|
-
calculate: (order: Order) => number;
|
|
797
|
-
}
|
|
798
|
-
```
|
|
799
|
-
|
|
800
|
-
Transitive inheritance does the rest. You attach the marker once on the right level of abstraction; codegen finds it on every implementation downstream.
|
|
801
|
-
|
|
802
|
-
#### Precedence
|
|
803
|
-
|
|
804
|
-
For any factory, the lifetime resolves in this order (highest first):
|
|
805
|
-
|
|
806
|
-
1. `registrations[Contract][impl].lifetime` — explicit per-impl override
|
|
807
|
-
2. Lifetime marker on the return type
|
|
808
|
-
3. `discovery.scanDirs[].scope` — folder-scoped default
|
|
809
|
-
4. Default: `singleton`
|
|
810
|
-
|
|
811
|
-
#### Multiple markers is a hard error
|
|
812
|
-
|
|
813
|
-
If a return type matches two markers, codegen errors and names both. Silent first-wins would create the worst kind of bug — a service's lifetime quietly differs from what the developer intended. Resolve by removing one marker from the inheritance chain or setting the lifetime explicitly via `registrations`.
|
|
814
|
-
|
|
815
|
-
#### Cross-package behavior
|
|
816
|
-
|
|
817
|
-
Marker types must be declared in source files visible to the package's TypeScript program at codegen — typically the same package's `src/`. Library packages bake their resolved lifetimes into their manifest at _their_ codegen time; composing apps do not re-run marker resolution on library factories. A library's choice of marker is invisible to consumers; what they see is the resolved lifetime in the registration.
|
|
818
|
-
|
|
819
|
-
### Folder-scoped lifetimes
|
|
820
|
-
|
|
821
|
-
Folder-scoped lifetimes are a **legacy pattern** for codebases where directory layout mirrors lifetime boundaries. For domain-organized code, prefer [lifetime markers](#lifetime-markers) instead.
|
|
822
|
-
|
|
823
|
-
If implementations are co-located by lifetime category, you can default lifetimes by scan root:
|
|
824
|
-
|
|
825
|
-
```ts
|
|
826
|
-
discovery: {
|
|
827
|
-
scanDirs: [
|
|
828
|
-
{ path: "src/services", scope: "scoped" },
|
|
829
|
-
{ path: "src/repos", scope: "scoped" },
|
|
830
|
-
{ path: "src/infra", scope: "singleton" },
|
|
831
|
-
{ path: "src/handlers", scope: "transient" },
|
|
832
|
-
],
|
|
833
|
-
},
|
|
834
|
-
```
|
|
835
|
-
|
|
836
|
-
This came out of a real pattern: in a GraphQL API, services and repositories are scoped to the request, infrastructure clients (database pools, caches) are singletons, and HTTP handlers are transient. Instead of repeating that in `registrations` for every single factory, you express it structurally — the directory _is_ the policy.
|
|
837
|
-
|
|
838
|
-
Per-implementation overrides in `registrations` and lifetime markers take precedence over folder scope.
|
|
839
|
-
|
|
840
|
-
### Lifetime inversion checks
|
|
841
|
-
|
|
842
|
-
Awilix lifetimes have an ordering: a `singleton` lives for the life of the container, a `scoped` instance lives for one scope (typically one request), a `transient` is rebuilt on every resolve. When a longer-lived registration depends on a shorter-lived one, the longer-lived service captures a single instance of that dependency at first construction and reuses it forever — quietly defeating the shorter lifetime.
|
|
843
|
-
|
|
844
|
-
The classic case: a `singleton` that depends on a `scoped` repository holding a per-request unit-of-work. The singleton is built once, captures one repository, and every later request writes through that first request's transaction. Nothing throws; the state just silently goes stale. The consumer doesn't even have to touch the scoped resource — holding something that holds it is enough.
|
|
845
|
-
|
|
846
|
-
`ioc generate` catches this statically. It walks every dependency edge over the resolved graph and flags any edge where the dependency is shorter-lived than the consumer:
|
|
847
|
-
|
|
848
|
-
- **`singleton → scoped`** is an **error** — generation fails. This includes a scoped dependency reached through a group (a group with a scoped member) or a scope-provided key (per-request, so effectively scoped). It is almost never intentional.
|
|
849
|
-
- **`singleton → transient`** and **`scoped → transient`** are **warnings** (`[ioc]`-prefixed). A singleton legitimately holding a transient factory it constructs from per use is a real pattern, so these surface for review without blocking.
|
|
850
|
-
|
|
851
|
-
The check resolves each demanded key precisely — a specific registration key, a contract's default slot, a collection, a group's members, or a scope-provided key — so it names the exact dependency rather than guessing across a contract's implementations. Findings aggregate: every warning prints, and if there are errors, generation throws once with the full list rather than failing on the first one.
|
|
852
|
-
|
|
853
|
-
A typical error:
|
|
854
|
-
|
|
855
|
-
```
|
|
856
|
-
Lifetime inversion: 'grantSync' (singleton) depends on 'grantRepository' (scoped). A singleton freezes its scoped dependency at first construction, reusing it across all scopes. Register 'grantSync' as scoped (or shorter), or mark it intentional with registrations['Grant'].grantSync.allowLifetimeInversion.
|
|
857
|
-
```
|
|
858
|
-
|
|
859
|
-
The usual fix is the obvious one — the consumer should be `scoped`:
|
|
860
|
-
|
|
861
|
-
```ts
|
|
862
|
-
registrations: {
|
|
863
|
-
GrantSync: {
|
|
864
|
-
grantSync: { lifetime: "scoped" },
|
|
865
|
-
},
|
|
866
|
-
},
|
|
867
|
-
```
|
|
868
|
-
|
|
869
|
-
**Intentional inversions.** If an inversion is deliberate — a singleton that holds a transient factory and constructs from it per call — opt out with `allowLifetimeInversion` on that implementation:
|
|
870
|
-
|
|
871
|
-
```ts
|
|
872
|
-
registrations: {
|
|
873
|
-
ConnectionPool: {
|
|
874
|
-
// allow all shorter-lived deps for this implementation:
|
|
875
|
-
connectionPool: { allowLifetimeInversion: true },
|
|
876
|
-
// or allow only specific demanded keys (preferred):
|
|
877
|
-
// connectionPool: { allowLifetimeInversion: ["connectionFactory"] },
|
|
878
|
-
},
|
|
879
|
-
},
|
|
880
|
-
```
|
|
881
|
-
|
|
882
|
-
Prefer the `string[]` form. `true` silences every inversion for that consumer — including ones you introduce later and didn't mean to. Listing the keys you're knowingly inverting keeps the rest of the check live. The field is config-only and never appears in the generated manifest.
|
|
883
|
-
|
|
884
|
-
### Groups
|
|
885
|
-
|
|
886
|
-
Groups collect implementations whose **contract types declare** `extends` on a shared base type (nominal membership — same rules as lifetime markers). There are two kinds — `collection` and `object` — and they solve different real-world problems. A group with no local members emits `[ioc-warn]` but still generates; members may come from other composed packages.
|
|
887
|
-
|
|
888
|
-
#### Collection groups: the strategy pattern
|
|
889
|
-
|
|
890
|
-
Say you have a pricing engine with five discount strategies, each implementing the same interface:
|
|
891
|
-
|
|
892
|
-
```ts
|
|
893
|
-
export type DiscountStrategy = {
|
|
894
|
-
applies: (order: Order) => boolean;
|
|
895
|
-
calculate: (order: Order) => number;
|
|
896
|
-
};
|
|
897
|
-
|
|
898
|
-
// buildVolumeDiscount.ts → DiscountStrategy
|
|
899
|
-
// buildSeasonalDiscount.ts → DiscountStrategy
|
|
900
|
-
// buildLoyaltyDiscount.ts → DiscountStrategy
|
|
901
|
-
// buildCouponDiscount.ts → DiscountStrategy
|
|
902
|
-
// buildBundleDiscount.ts → DiscountStrategy
|
|
903
|
-
```
|
|
904
|
-
|
|
905
|
-
Without groups, you'd have to manually wire all five into an array. With a collection group:
|
|
906
|
-
|
|
907
|
-
```ts
|
|
908
|
-
groups: {
|
|
909
|
-
discountStrategies: {
|
|
910
|
-
kind: "collection",
|
|
911
|
-
baseType: "DiscountStrategy",
|
|
912
|
-
},
|
|
913
|
-
},
|
|
914
|
-
```
|
|
915
|
-
|
|
916
|
-
Now `container.resolve("discountStrategies")` gives you `ReadonlyArray<DiscountStrategy>` — every implementation whose contract type declares `extends DiscountStrategy`, discovered automatically. Your strategy runner just iterates through the array:
|
|
917
|
-
|
|
918
|
-
```ts
|
|
919
|
-
type PricingEngineDeps = {
|
|
920
|
-
discountStrategies: ReadonlyArray<DiscountStrategy>;
|
|
921
|
-
};
|
|
922
|
-
|
|
923
|
-
export const buildPricingEngine = ({
|
|
924
|
-
discountStrategies,
|
|
925
|
-
}: PricingEngineDeps): PricingEngine => ({
|
|
926
|
-
applyDiscounts: (order) => {
|
|
927
|
-
for (const strategy of discountStrategies) {
|
|
928
|
-
if (strategy.applies(order)) {
|
|
929
|
-
order.discount += strategy.calculate(order);
|
|
930
|
-
}
|
|
931
|
-
}
|
|
932
|
-
return order;
|
|
933
|
-
},
|
|
934
|
-
});
|
|
935
|
-
```
|
|
936
|
-
|
|
937
|
-
Add a sixth strategy? Just create the factory. It shows up in the group automatically — no registration changes.
|
|
938
|
-
|
|
939
|
-
If you need strategies to run in a specific order, put ordering metadata on the strategy interface itself (e.g. a `priority` field) and sort at use time. The library never tries to order group members.
|
|
940
|
-
|
|
941
|
-
#### Object groups: bundling related services
|
|
942
|
-
|
|
943
|
-
Object groups are for when you have several services that implement a common base type and you want to access them as a keyed bundle rather than an array. A real example: in a GraphQL API, you might have a set of user-scoped read services that all need to be available on the resolver context:
|
|
944
|
-
|
|
945
|
-
```ts
|
|
946
|
-
export type ReadService = {
|
|
947
|
-
readonly scope: "user";
|
|
948
|
-
};
|
|
949
|
-
|
|
950
|
-
// buildUserReadService.ts → UserReadService (extends ReadService)
|
|
951
|
-
// buildOrderReadService.ts → OrderReadService (extends ReadService)
|
|
952
|
-
// buildNotificationReadService.ts → NotificationReadService (extends ReadService)
|
|
953
|
-
```
|
|
954
|
-
|
|
955
|
-
```ts
|
|
956
|
-
groups: {
|
|
957
|
-
readServices: {
|
|
958
|
-
kind: "object",
|
|
959
|
-
baseType: "ReadService",
|
|
960
|
-
},
|
|
961
|
-
},
|
|
962
|
-
```
|
|
963
|
-
|
|
964
|
-
Now `container.resolve("readServices")` returns an object keyed by each contract's convention name — `{ userReadService: UserReadService, orderReadService: OrderReadService, ... }`. You can spread that straight onto your GraphQL context without importing each service individually.
|
|
965
|
-
|
|
966
|
-
#### Group validation
|
|
967
|
-
|
|
968
|
-
The generator validates that group names don't collide with implementation keys, access keys, or collection keys. If a base type has no assignable implementations, generation fails with an actionable error. Cross-manifest group composition is covered in [Cross-package composition](#cross-package-composition).
|
|
969
|
-
|
|
970
|
-
#### Consuming a group from the same package
|
|
971
|
-
|
|
972
|
-
A factory can consume a group declared in its own package. The group's aggregate type — the array for a collection, the keyed object for an object group — is generated, so there's no hand-written type to import. You name it by indexing the generated cradle inside your deps type:
|
|
973
|
-
|
|
974
|
-
```ts
|
|
975
|
-
import type { IocGeneratedCradle } from "./generated/ioc-registry.types.js";
|
|
976
|
-
import type { NotificationService } from "./channel-contracts.js";
|
|
977
|
-
|
|
978
|
-
type NotificationServiceDeps = {
|
|
979
|
-
channels: IocGeneratedCradle["channels"];
|
|
980
|
-
};
|
|
981
|
-
|
|
982
|
-
export const buildNotificationService = ({
|
|
983
|
-
channels,
|
|
984
|
-
}: NotificationServiceDeps): NotificationService => ({
|
|
985
|
-
notifyAll: (to) => {
|
|
986
|
-
channels.emailChannel.sendEmail(to);
|
|
987
|
-
channels.smsChannel.sendSms(to);
|
|
988
|
-
},
|
|
989
|
-
});
|
|
990
|
-
```
|
|
991
|
-
|
|
992
|
-
This is the one sanctioned use of `IocGeneratedCradle` in a factory. The [named-deps-type rule](#1-create-factories) still holds: the parameter binds to a named type (`NotificationServiceDeps`), and `IocGeneratedCradle["channels"]` appears only as a _type reference inside it_, to name the otherwise-unnameable group type. You still cannot bind the parameter directly to the cradle (`({ channels }: IocGeneratedCradle)`).
|
|
993
|
-
|
|
994
|
-
For an object group, members are keyed by their convention name — `channels.emailChannel`, `channels.smsChannel`, the same registration keys derived from `buildEmailChannel` and `buildSmsChannel`. A collection group indexes to `ReadonlyArray<BaseType>` instead.
|
|
995
|
-
|
|
996
|
-
A few things work as you'd expect:
|
|
997
|
-
|
|
998
|
-
- **Aliased imports.** `import { IocGeneratedCradle as Cradle }`, then `Cradle["channels"]`, resolves identically.
|
|
999
|
-
- **Cold start.** The reference resolves from your source, not from a previously generated file — so first-run generation, or generation after deleting the generated directory, works. There's no chicken-and-egg dependency on prior output.
|
|
1000
|
-
- **Typos throw.** Indexing a key that is neither a registration nor a declared group — `IocGeneratedCradle["channel"]` when the group is `channels` — fails generation with a diagnostic naming the offending key, instead of silently resolving to `unknown`.
|
|
1001
|
-
|
|
1002
|
-
### Environment-specific configs
|
|
1003
|
-
|
|
1004
|
-
The separation between factory code and `ioc.config.ts` makes it straightforward to swap implementations by environment. Your factories don't change — the config (or the set of composed manifests) is the only thing that differs.
|
|
1005
|
-
|
|
1006
|
-
For a single-package app, point the generator at a different config:
|
|
1007
|
-
|
|
1008
|
-
```bash
|
|
1009
|
-
npx ioc generate --config ./ioc.config.test.ts
|
|
1010
|
-
```
|
|
1011
|
-
|
|
1012
|
-
For a monorepo app, you can swap `composedManifests` entries to compose with mock packages in tests:
|
|
1013
|
-
|
|
1014
|
-
```ts
|
|
1015
|
-
// ioc.config.test.ts
|
|
1016
|
-
composedManifests: [
|
|
1017
|
-
"@example/lib-storage-mock", // a sibling test-only package
|
|
1018
|
-
"@example/lib-services",
|
|
1019
|
-
],
|
|
6
|
+
npm install ioc-manifest
|
|
1020
7
|
```
|
|
1021
8
|
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
---
|
|
1025
|
-
|
|
1026
|
-
## Pitfalls and troubleshooting
|
|
1027
|
-
|
|
1028
|
-
**Manifest out of date** — regenerate after editing factories or `ioc.config`. The generated files are build artifacts; treat them like compiled output.
|
|
1029
|
-
|
|
1030
|
-
**Contract not discovered** — the factory's return type must resolve to a named type (interface or type alias). The contract symbol must be imported or declared in the same file as the factory. Anonymous `{ foo: string }` return types are silently skipped.
|
|
1031
|
-
|
|
1032
|
-
**Factory destructures `IocGeneratedCradle`** — not allowed. Use a named local deps type instead. The error message names the factory and shows the correct pattern.
|
|
1033
|
-
|
|
1034
|
-
**Duplicate registration keys within a manifest** — every implementation needs a globally unique Awilix key. If two factories produce the same key, rename the exports or use `registrations[Contract][impl].name` to override.
|
|
1035
|
-
|
|
1036
|
-
**Duplicate registration keys across composed manifests** — composition errors with both manifest sources named. Resolve via `registrations[Contract][impl].source` in the app's `ioc.config`.
|
|
1037
|
-
|
|
1038
|
-
**Overlapping scan directories with different scopes** — if a factory file matches multiple scan roots that specify different `scope` values, generation fails. Narrow the roots or set lifetimes per implementation in `registrations`.
|
|
9
|
+
## Documentation
|
|
1039
10
|
|
|
1040
|
-
|
|
11
|
+
Full documentation lives at **[reharik.github.io/ioc-manifest](https://reharik.github.io/ioc-manifest/)**.
|
|
1041
12
|
|
|
1042
|
-
|
|
13
|
+
- [Introduction](https://reharik.github.io/ioc-manifest/guide/introduction) — the problem, what this does, library vs app mode
|
|
14
|
+
- [Quick start](https://reharik.github.io/ioc-manifest/guide/quick-start) — factories → config → generate → bootstrap
|
|
15
|
+
- [Core concepts](https://reharik.github.io/ioc-manifest/concepts/conventions) — conventions, [lifetimes](https://reharik.github.io/ioc-manifest/concepts/lifetimes), [groups](https://reharik.github.io/ioc-manifest/concepts/groups)
|
|
16
|
+
- [`ioc.config.ts` reference](https://reharik.github.io/ioc-manifest/config/reference) — the single policy surface
|
|
17
|
+
- [Cross-package composition](https://reharik.github.io/ioc-manifest/monorepo/composition) — monorepo app mode
|
|
18
|
+
- [CLI](https://reharik.github.io/ioc-manifest/reference/cli) · [Error handling](https://reharik.github.io/ioc-manifest/reference/errors) · [Pitfalls](https://reharik.github.io/ioc-manifest/reference/pitfalls)
|
|
1043
19
|
|
|
1044
|
-
|
|
20
|
+
## What you get
|
|
1045
21
|
|
|
1046
|
-
|
|
22
|
+
- **Auto-discovery** — export `buildUserService`, it's registered as `userService` returning `UserService`
|
|
23
|
+
- **Typed end-to-end** — `container.resolve("userService")` returns `UserService`, not `any`
|
|
24
|
+
- **Plural collections** — multiple implementations of a contract get a `ReadonlyArray` key automatically
|
|
25
|
+
- **Cross-package composition** — compose manifests across a monorepo with compile-time externals checks
|
|
26
|
+
- **Lifetime-inversion safety** — generation fails when a longer-lived service would freeze a shorter-lived dependency
|
|
27
|
+
- **No runtime scanning** — output is plain Awilix with static imports; zero lock-in, works in dev and bundled prod
|
|
1047
28
|
|
|
1048
|
-
|
|
29
|
+
## Contributing to the docs
|
|
1049
30
|
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
**Every factory in the package got the same lifetime** (v1.1.x and earlier) — that was structural matching on empty markers. Upgrade to v1.2.0+ and use `extends` on the types that should be scoped; empty markers are safe when inheritance is declared explicitly.
|
|
1053
|
-
|
|
1054
|
-
**A singleton silently reuses a per-request dependency** — if a `singleton` depends (directly or through a chain) on a `scoped` or scope-provided value, it captures one instance at first construction and never refreshes it; per-request state goes stale with no runtime error. `ioc generate` fails on `singleton → scoped` edges for exactly this reason. Make the consumer `scoped`, or mark deliberate cases with `allowLifetimeInversion`. See [Lifetime inversion checks](#lifetime-inversion-checks).
|
|
1055
|
-
|
|
1056
|
-
---
|
|
1057
|
-
|
|
1058
|
-
## Design philosophy
|
|
1059
|
-
|
|
1060
|
-
This package is **not** an IoC container. It is a codegen layer over Awilix that trades manual registration for convention.
|
|
1061
|
-
|
|
1062
|
-
- **Factories are plain functions.** No decorators, no base classes, no `RESOLVER` symbols. A factory is an exported function that takes a named deps type and returns a value.
|
|
1063
|
-
- **Policy lives in one file.** Lifetimes, defaults, and key overrides are in `ioc.config.ts` — never scattered across factory sources. Looking at a factory tells you _what_ it builds; looking at the config tells you _how_ it's registered.
|
|
1064
|
-
- **Types are inferred, not declared.** The generator reads the TypeScript program to discover contracts, dependencies, and assignability. You don't maintain a parallel type registry.
|
|
1065
|
-
- **Library packages own their boundary.** Each package generates its own manifest. What it supplies appears in `IocGeneratedCradle`; what it expects from outside appears in `IocExternals`. The contract is explicit and machine-readable.
|
|
1066
|
-
- **App-mode composition is set-like.** `registerIocFromManifest(container, [a, b, c])` is order-independent. Conflicts are hard errors with explicit resolution, never silent override.
|
|
1067
|
-
- **Errors fail fast and explain themselves.** Ambiguous defaults, key collisions, missing externals, and base-type mismatches are caught at generation, validation, or compile time — with messages that name the problem, suggest a fix, and where possible give you the exact config block to paste.
|
|
1068
|
-
- **Static imports, not runtime scanning.** The generated manifest is a plain TypeScript module with static imports. It works in dev with loose source files and in production with a single bundled file — no filesystem walking at runtime.
|
|
1069
|
-
|
|
1070
|
-
---
|
|
1071
|
-
|
|
1072
|
-
## Installation
|
|
31
|
+
The docs are a [VitePress](https://vitepress.dev/) site under `docs/`.
|
|
1073
32
|
|
|
1074
33
|
```bash
|
|
1075
|
-
npm
|
|
34
|
+
npm run docs:dev # local dev server with hot reload
|
|
35
|
+
npm run docs:build # production build
|
|
36
|
+
npm run docs:preview # preview the production build
|
|
1076
37
|
```
|
|
1077
38
|
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
`ioc-manifest` bundles `typescript` and `prettier` as dependencies because it uses the TypeScript compiler API for source analysis and Prettier for formatting generated output. If your project uses a different TypeScript version, they coexist without conflict (the generator uses its own copy).
|
|
1081
|
-
|
|
1082
|
-
---
|
|
39
|
+
Pushing changes under `docs/` to `main` deploys to GitHub Pages via `.github/workflows/deploy-docs.yml`.
|
|
1083
40
|
|
|
1084
41
|
## License
|
|
1085
42
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ioc-manifest",
|
|
3
|
-
"version": "1.5.
|
|
3
|
+
"version": "1.5.1",
|
|
4
4
|
"description": "B uild-time factory discovery and codegen: generates a typed Awilix (PROXY) registration manifest from TypeScript sources with ioc.config as the single policy surface.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -60,15 +60,18 @@
|
|
|
60
60
|
"example:start": "npm run start --prefix examples/multi-package",
|
|
61
61
|
"example:typecheck-broken": "npm run typecheck:broken-expect-fail --prefix examples/multi-package",
|
|
62
62
|
"example:validate-broken-expect-fail": "npm run validate:broken-expect-fail --prefix examples/multi-package",
|
|
63
|
-
"example:full": "npm run example:install && npm run full --prefix examples/multi-package"
|
|
63
|
+
"example:full": "npm run example:install && npm run full --prefix examples/multi-package",
|
|
64
|
+
"docs:dev": "vitepress dev docs",
|
|
65
|
+
"docs:build": "vitepress build docs",
|
|
66
|
+
"docs:preview": "vitepress preview docs"
|
|
64
67
|
},
|
|
65
68
|
"dependencies": {
|
|
66
69
|
"awilix": "^12.0.5",
|
|
67
70
|
"fast-glob": "^3.3.3"
|
|
68
71
|
},
|
|
69
72
|
"peerDependencies": {
|
|
70
|
-
"
|
|
71
|
-
"
|
|
73
|
+
"prettier": "^3.0.0",
|
|
74
|
+
"typescript": "^5.0.0"
|
|
72
75
|
},
|
|
73
76
|
"peerDependenciesMeta": {
|
|
74
77
|
"prettier": {
|
|
@@ -79,6 +82,7 @@
|
|
|
79
82
|
"@types/node": "^22.10.0",
|
|
80
83
|
"prettier": "^3.4.2",
|
|
81
84
|
"tsx": "^4.19.2",
|
|
82
|
-
"typescript": "^5.0.0"
|
|
85
|
+
"typescript": "^5.0.0",
|
|
86
|
+
"vitepress": "^1.6.4"
|
|
83
87
|
}
|
|
84
88
|
}
|