@cyrenex/pondi 0.0.0-stage → 0.1.0
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/LICENSE +21 -0
- package/README.md +262 -2
- package/README.zh-CN.md +263 -0
- package/dist/index.d.mts +93 -0
- package/dist/index.mjs +264 -0
- package/package.json +46 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 byc
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,263 @@
|
|
|
1
|
-
|
|
1
|
+
<h1 align="center">pondi</h1>
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
<p align="center">Drop a pebble, get a ripple. Type-safe, string-keyed dependency injection for TypeScript.</p>
|
|
4
|
+
|
|
5
|
+
<p align="center">
|
|
6
|
+
[<a href="https://github.com/cyrenex/pondi/blob/master/README.zh-CN.md">简体中文</a>] [<a href="https://www.npmjs.com/package/@cyrenex/pondi">npm</a>]
|
|
7
|
+
</p>
|
|
8
|
+
|
|
9
|
+
<p align="center">
|
|
10
|
+
<a href="https://www.npmjs.com/package/@cyrenex/pondi"><img src="https://img.shields.io/npm/v/@cyrenex/pondi?style=flat&colorA=080f12&colorB=1fa669"></a>
|
|
11
|
+
<a href="https://www.npmjs.com/package/@cyrenex/pondi"><img src="https://img.shields.io/npm/dm/@cyrenex/pondi?style=flat&colorA=080f12&colorB=1fa669"></a>
|
|
12
|
+
<a href="https://bundlephobia.com/package/@cyrenex/pondi"><img src="https://img.shields.io/bundlephobia/minzip/@cyrenex/pondi?style=flat&colorA=080f12&colorB=1fa669&label=minzip"></a>
|
|
13
|
+
<a href="https://github.com/cyrenex/pondi/blob/master/LICENSE"><img src="https://img.shields.io/github/license/cyrenex/pondi.svg?style=flat&colorA=080f12&colorB=1fa669"></a>
|
|
14
|
+
</p>
|
|
15
|
+
|
|
16
|
+
> [!NOTE]
|
|
17
|
+
> No decorators, no `reflect-metadata`, no runtime dependencies. Just keys, factories, and types.
|
|
18
|
+
|
|
19
|
+
Most DI containers in TypeScript lean on decorators and emitted metadata, and you pay for that with build configuration and a class-only style. pondi goes the other way: **a dependency is a string key with a type attached**. You declare what you need with a `pebble`, describe how to build it with a `ripple`, and let a `Pond` wire everything together.
|
|
20
|
+
|
|
21
|
+
Think of it as dropping a pebble into a pond: the pebble names what you need, the ripple is what it becomes.
|
|
22
|
+
|
|
23
|
+
## What's Special
|
|
24
|
+
|
|
25
|
+
- **Inferred types everywhere**: factory parameters are inferred from the dependency list, and wrong orders or shapes fail at compile time.
|
|
26
|
+
- **Async all the way**: async factories, async `init` hooks, and concurrent resolves that run each factory only once.
|
|
27
|
+
- **Hierarchical ponds**: `fork()` a child to override providers for tests, requests, or tenants; only the affected singletons are rebuilt.
|
|
28
|
+
- **Deterministic disposal**: works with `await using`; children first, then instances in reverse creation order.
|
|
29
|
+
- **Helpful errors**: every `PondError` carries a `code` and the full dependency `path`.
|
|
30
|
+
|
|
31
|
+
## Installation
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pnpm add @cyrenex/pondi
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Quick Start
|
|
38
|
+
|
|
39
|
+
```ts
|
|
40
|
+
import { pebble, Pond, ripple } from "@cyrenex/pondi";
|
|
41
|
+
|
|
42
|
+
interface AgentOptions {
|
|
43
|
+
url: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// A contract: a type, no implementation
|
|
47
|
+
const AgentConfig = pebble<AgentOptions>("agent:config");
|
|
48
|
+
|
|
49
|
+
// A plugin: provides an implementation for the contract
|
|
50
|
+
const agentConfigPlugin = (options: AgentOptions) => ripple(AgentConfig, () => options);
|
|
51
|
+
|
|
52
|
+
// A token with a default implementation
|
|
53
|
+
const Message = ripple("message", () => "Hello, Pond!");
|
|
54
|
+
|
|
55
|
+
// Declare dependencies; factory parameter types are inferred
|
|
56
|
+
const Agent = ripple("agent", [Message, AgentConfig], async (message, config) => {
|
|
57
|
+
const connection = await connect(config.url);
|
|
58
|
+
return {
|
|
59
|
+
send: () => connection.send(message),
|
|
60
|
+
async [Symbol.asyncDispose]() {
|
|
61
|
+
await connection.close();
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
await using app = new Pond().use(agentConfigPlugin({ url: "ws://localhost" }));
|
|
67
|
+
const agent = await app.resolve(Agent); // { send: () => void }
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Core Concepts
|
|
71
|
+
|
|
72
|
+
| Concept | Description |
|
|
73
|
+
| ----------- | ------------------------------------------------------------------------------ |
|
|
74
|
+
| `pebble<T>` | A contract: a typed string key with no implementation |
|
|
75
|
+
| `ripple` | An implementation: key + dependencies + factory + mode; also usable as a token |
|
|
76
|
+
| `Pond` | The container: registers implementations, resolves instances, owns lifecycles |
|
|
77
|
+
|
|
78
|
+
**The key is the identity.** `ripple("message", ...)`, `pebble("message")`, and the string `"message"` all refer to the same token, so `app.resolve(Message)` and `app.resolve("message")` return the same instance. Namespace your keys to avoid collisions, e.g. `"agent:config"`.
|
|
79
|
+
|
|
80
|
+
## Declaring Ripples
|
|
81
|
+
|
|
82
|
+
```ts
|
|
83
|
+
ripple(key, factory, options?)
|
|
84
|
+
ripple(key, deps, factory, options?)
|
|
85
|
+
ripple(pebble, factory, options?) // implements a pebble; the return type must match the contract
|
|
86
|
+
ripple(pebble, deps, factory, options?)
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
- `deps` may mix pebbles, ripples, and strings. The factory receives the resolved values in order.
|
|
90
|
+
- Factories may be async. The container awaits them, so dependents always receive values, never promises.
|
|
91
|
+
- `options.mode` defaults to `"singleton"`. See [Lifecycle Modes](#lifecycle-modes).
|
|
92
|
+
|
|
93
|
+
### ripple.class
|
|
94
|
+
|
|
95
|
+
Same signatures as `ripple`, but takes a class instead of a factory. Dependencies are passed to the constructor in order:
|
|
96
|
+
|
|
97
|
+
```ts
|
|
98
|
+
class Agent {
|
|
99
|
+
constructor(
|
|
100
|
+
readonly message: string,
|
|
101
|
+
readonly config: AgentOptions,
|
|
102
|
+
) {}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const AgentRipple = ripple.class("agent", [Message, AgentConfig], Agent);
|
|
106
|
+
const Logger = ripple.class(LoggerPebble, ConsoleLogger);
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Constructor parameters are type-checked against the dependency list at compile time, so listing dependencies in the wrong order is an error.
|
|
110
|
+
|
|
111
|
+
## Resolving and Types
|
|
112
|
+
|
|
113
|
+
```ts
|
|
114
|
+
await app.resolve(Agent); // typed by the ripple
|
|
115
|
+
await app.resolve(AgentConfig); // typed by the pebble
|
|
116
|
+
await app.resolve("message"); // typed by Registry, or unknown if not declared
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
To give plain string keys a type, augment `Registry`. Augmented keys get autocompletion, and `ripple` checks its return type against the declaration:
|
|
120
|
+
|
|
121
|
+
```ts
|
|
122
|
+
declare module "@cyrenex/pondi" {
|
|
123
|
+
interface Registry {
|
|
124
|
+
message: string;
|
|
125
|
+
"agent:config": AgentOptions;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## Resolution Rules
|
|
131
|
+
|
|
132
|
+
To resolve a token, the container looks for an implementation in this order:
|
|
133
|
+
|
|
134
|
+
1. Implementations registered with `use` in the current container or any ancestor; the nearest one wins.
|
|
135
|
+
2. If the token itself is a ripple, its own default implementation.
|
|
136
|
+
3. Otherwise it throws `MISSING_PROVIDER`, with the full dependency path, e.g. `agent -> agent:config`.
|
|
137
|
+
|
|
138
|
+
So a ripple can be resolved without calling `use` first. Pebbles and plain strings need some container to provide them.
|
|
139
|
+
|
|
140
|
+
## Registering and Overriding
|
|
141
|
+
|
|
142
|
+
### use
|
|
143
|
+
|
|
144
|
+
```ts
|
|
145
|
+
app.use(Message, agentConfigPlugin(opts), [PluginA, PluginB]); // arrays are accepted, so plugins can return several ripples
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
- Registering the same ripple object again has no effect.
|
|
149
|
+
- Registering a different implementation for an existing key throws `DUPLICATE_PROVIDER`.
|
|
150
|
+
- Once a key has been resolved, it can no longer be registered in that container or its ancestors (`PROVIDER_LOCKED`). This prevents old and new instances from coexisting.
|
|
151
|
+
|
|
152
|
+
### fork
|
|
153
|
+
|
|
154
|
+
There is one way to override an implementation: create a child container and register the same key in it.
|
|
155
|
+
|
|
156
|
+
```ts
|
|
157
|
+
await using test = app.fork().use(ripple("message", () => "mock"));
|
|
158
|
+
|
|
159
|
+
await test.resolve(Agent); // uses the mocked message
|
|
160
|
+
await app.resolve(Agent); // the parent is unaffected
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
In the child, **singletons affected by an override are rebuilt**, while unaffected singletons stay shared with the parent. Precisely: a singleton is cached in the deepest container that provides it or any of its transitive dependencies.
|
|
164
|
+
|
|
165
|
+
Use cases: test mocks, request-scoped context, multi-tenancy, replacing part of a plugin.
|
|
166
|
+
|
|
167
|
+
## Lifecycle Modes
|
|
168
|
+
|
|
169
|
+
| mode | Where instances are cached | Disposed by |
|
|
170
|
+
| --------------------- | ------------------------------------------ | ------------------------ |
|
|
171
|
+
| `singleton` (default) | The container chosen by the rule above | The container caching it |
|
|
172
|
+
| `scoped` | The resolving container, one per container | That container |
|
|
173
|
+
| `transient` | Not cached; a new instance per resolve | The resolving container |
|
|
174
|
+
|
|
175
|
+
- A singleton that depends on a scoped provider, directly or transitively, throws `CAPTIVE_DEPENDENCY`.
|
|
176
|
+
- Concurrent resolves of the same singleton run the factory only once.
|
|
177
|
+
- Failed factories or initializations are not cached; the next resolve retries.
|
|
178
|
+
|
|
179
|
+
## Async Initialization and Disposal
|
|
180
|
+
|
|
181
|
+
```ts
|
|
182
|
+
import { asyncDispose, dispose, init } from "@cyrenex/pondi";
|
|
183
|
+
|
|
184
|
+
class Db {
|
|
185
|
+
#conn!: Connection;
|
|
186
|
+
constructor(readonly config: DbConfig) {}
|
|
187
|
+
|
|
188
|
+
async [init]() {
|
|
189
|
+
this.#conn = await connect(this.config.url);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async [asyncDispose]() {
|
|
193
|
+
await this.#conn.close();
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
- **`init`**: after creating an instance, the container awaits `value[init]()` before handing it to dependents or caching it. Use it for async setup a constructor can't do. If `init` throws, the container disposes the instance and rethrows the original error.
|
|
199
|
+
- **`asyncDispose` / `dispose`**: the standard `Symbol.asyncDispose` / `Symbol.dispose`, falling back to `Symbol.for(...)` on runtimes that lack them. They work with `await using` / `using`.
|
|
200
|
+
- Both apply to objects returned by `ripple` factories and to instances created by `ripple.class`.
|
|
201
|
+
|
|
202
|
+
Dispose a container with `await using` or `await app.dispose()`. Disposal order:
|
|
203
|
+
|
|
204
|
+
1. Child containers first.
|
|
205
|
+
2. Then instances owned by this container, in reverse creation order, so dependents are disposed before their dependencies.
|
|
206
|
+
3. Multiple disposal errors are combined into one `AggregateError`.
|
|
207
|
+
|
|
208
|
+
After disposal, `resolve`, `use`, and `fork` throw `DISPOSED`.
|
|
209
|
+
|
|
210
|
+
## Errors
|
|
211
|
+
|
|
212
|
+
All container errors are `PondError` instances with a `code` and a dependency `path`:
|
|
213
|
+
|
|
214
|
+
| code | Thrown when |
|
|
215
|
+
| --------------------- | ------------------------------------------------------------------- |
|
|
216
|
+
| `MISSING_PROVIDER` | No implementation exists for a key |
|
|
217
|
+
| `DUPLICATE_PROVIDER` | A different implementation is registered for a key in one container |
|
|
218
|
+
| `PROVIDER_LOCKED` | Registering an implementation for a key that was already resolved |
|
|
219
|
+
| `CIRCULAR_DEPENDENCY` | Dependencies form a cycle |
|
|
220
|
+
| `CAPTIVE_DEPENDENCY` | A singleton depends on a scoped provider, directly or transitively |
|
|
221
|
+
| `DISPOSED` | The container has been disposed |
|
|
222
|
+
|
|
223
|
+
```ts
|
|
224
|
+
try {
|
|
225
|
+
await app.resolve(Agent);
|
|
226
|
+
} catch (error) {
|
|
227
|
+
if (error instanceof PondError && error.code === "MISSING_PROVIDER") {
|
|
228
|
+
console.error(error.path); // ["agent", "agent:config"]
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
## Development
|
|
234
|
+
|
|
235
|
+
```bash
|
|
236
|
+
vp install # install dependencies
|
|
237
|
+
vp test # run tests
|
|
238
|
+
vp check # format, lint, and type check
|
|
239
|
+
vp pack # build
|
|
240
|
+
vp run changeset # describe a change for the next release
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
## License
|
|
244
|
+
|
|
245
|
+
[MIT](./LICENSE)
|
|
246
|
+
|
|
247
|
+
## Special Thanks
|
|
248
|
+
|
|
249
|
+
Thanks to all contributors for their contributions to pondi ❤️
|
|
250
|
+
|
|
251
|
+
<a href="https://github.com/cyrenex/pondi/graphs/contributors">
|
|
252
|
+
<img src="https://contrib.rocks/image?repo=cyrenex/pondi" />
|
|
253
|
+
</a>
|
|
254
|
+
|
|
255
|
+
## Star History
|
|
256
|
+
|
|
257
|
+
<a href="https://star-history.dera.page/#cyrenex/pondi&Date">
|
|
258
|
+
<picture>
|
|
259
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://star-history.dera.page/svg?repos=cyrenex/pondi&type=Date&theme=dark" />
|
|
260
|
+
<source media="(prefers-color-scheme: light)" srcset="https://star-history.dera.page/svg?repos=cyrenex/pondi&type=Date" />
|
|
261
|
+
<img alt="Star History Chart" src="https://star-history.dera.page/svg?repos=cyrenex/pondi&type=Date" />
|
|
262
|
+
</picture>
|
|
263
|
+
</a>
|
package/README.zh-CN.md
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
<h1 align="center">pondi</h1>
|
|
2
|
+
|
|
3
|
+
<p align="center">投下一颗石子,泛起一圈涟漪。以字符串为 key 的 TypeScript 类型安全依赖注入库。</p>
|
|
4
|
+
|
|
5
|
+
<p align="center">
|
|
6
|
+
[<a href="https://github.com/cyrenex/pondi/blob/master/README.md">English</a>] [<a href="https://www.npmjs.com/package/@cyrenex/pondi">npm</a>]
|
|
7
|
+
</p>
|
|
8
|
+
|
|
9
|
+
<p align="center">
|
|
10
|
+
<a href="https://www.npmjs.com/package/@cyrenex/pondi"><img src="https://img.shields.io/npm/v/@cyrenex/pondi?style=flat&colorA=080f12&colorB=1fa669"></a>
|
|
11
|
+
<a href="https://www.npmjs.com/package/@cyrenex/pondi"><img src="https://img.shields.io/npm/dm/@cyrenex/pondi?style=flat&colorA=080f12&colorB=1fa669"></a>
|
|
12
|
+
<a href="https://bundlephobia.com/package/@cyrenex/pondi"><img src="https://img.shields.io/bundlephobia/minzip/@cyrenex/pondi?style=flat&colorA=080f12&colorB=1fa669&label=minzip"></a>
|
|
13
|
+
<a href="https://github.com/cyrenex/pondi/blob/master/LICENSE"><img src="https://img.shields.io/github/license/cyrenex/pondi.svg?style=flat&colorA=080f12&colorB=1fa669"></a>
|
|
14
|
+
</p>
|
|
15
|
+
|
|
16
|
+
> [!NOTE]
|
|
17
|
+
> 不需要装饰器,不依赖 `reflect-metadata`,没有运行时依赖。只有 key、工厂和类型。
|
|
18
|
+
|
|
19
|
+
TypeScript 里的大多数 DI 容器依赖装饰器和编译期生成的元数据,代价是额外的构建配置和只能写 class 的风格。pondi 反其道而行:**一个依赖就是一个带类型的字符串 key**。用 `pebble` 声明你需要什么,用 `ripple` 描述怎么构建它,再交给 `Pond` 把一切串起来。
|
|
20
|
+
|
|
21
|
+
可以这样理解:往池塘里投一颗石子,石子(pebble)说明你需要什么,涟漪(ripple)是它最终变成的东西。
|
|
22
|
+
|
|
23
|
+
## 特性
|
|
24
|
+
|
|
25
|
+
- **全程类型推导**:工厂参数类型由依赖列表推导,顺序或结构写错会在编译期报错。
|
|
26
|
+
- **彻底的异步支持**:异步工厂、异步 `init` 钩子,并发 resolve 同一个依赖时工厂只执行一次。
|
|
27
|
+
- **分层池塘**:用 `fork()` 创建子容器,为测试、请求或租户覆盖实现;只有受影响的单例会被重建。
|
|
28
|
+
- **确定的释放顺序**:支持 `await using`;先释放子容器,再按创建顺序的逆序释放实例。
|
|
29
|
+
- **有用的错误信息**:每个 `PondError` 都带有 `code` 和完整的依赖路径 `path`。
|
|
30
|
+
|
|
31
|
+
## 安装
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pnpm add @cyrenex/pondi
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## 快速开始
|
|
38
|
+
|
|
39
|
+
```ts
|
|
40
|
+
import { pebble, Pond, ripple } from "@cyrenex/pondi";
|
|
41
|
+
|
|
42
|
+
interface AgentOptions {
|
|
43
|
+
url: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// 契约:只有类型,没有实现
|
|
47
|
+
const AgentConfig = pebble<AgentOptions>("agent:config");
|
|
48
|
+
|
|
49
|
+
// 插件:为契约提供实现
|
|
50
|
+
const agentConfigPlugin = (options: AgentOptions) => ripple(AgentConfig, () => options);
|
|
51
|
+
|
|
52
|
+
// 带默认实现的 token
|
|
53
|
+
const Message = ripple("message", () => "Hello, Pond!");
|
|
54
|
+
|
|
55
|
+
// 声明依赖,工厂参数的类型会自动推导
|
|
56
|
+
const Agent = ripple("agent", [Message, AgentConfig], async (message, config) => {
|
|
57
|
+
const connection = await connect(config.url);
|
|
58
|
+
return {
|
|
59
|
+
send: () => connection.send(message),
|
|
60
|
+
async [Symbol.asyncDispose]() {
|
|
61
|
+
await connection.close();
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
await using app = new Pond().use(agentConfigPlugin({ url: "ws://localhost" }));
|
|
67
|
+
const agent = await app.resolve(Agent); // { send: () => void }
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## 核心概念
|
|
71
|
+
|
|
72
|
+
| 概念 | 说明 |
|
|
73
|
+
| ----------- | ------------------------------------------------------------- |
|
|
74
|
+
| `pebble<T>` | 契约:一个带类型的字符串 key,没有实现 |
|
|
75
|
+
| `ripple` | 实现:key + 依赖 + 工厂 + 生命周期模式,同时也可以当 token 用 |
|
|
76
|
+
| `Pond` | 容器:注册实现、解析实例、管理生命周期 |
|
|
77
|
+
|
|
78
|
+
**key 就是身份。** `ripple("message", ...)`、`pebble("message")` 和字符串 `"message"` 指向同一个 token,`app.resolve(Message)` 与 `app.resolve("message")` 返回的是同一个实例。建议用命名空间来避免撞名,例如 `"agent:config"`。
|
|
79
|
+
|
|
80
|
+
## 声明 ripple
|
|
81
|
+
|
|
82
|
+
```ts
|
|
83
|
+
ripple(key, factory, options?)
|
|
84
|
+
ripple(key, deps, factory, options?)
|
|
85
|
+
ripple(pebble, factory, options?) // 为 pebble 提供实现,返回值类型必须符合契约
|
|
86
|
+
ripple(pebble, deps, factory, options?)
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
- `deps` 里可以混写 pebble、ripple 和字符串,工厂按顺序收到解析好的值。
|
|
90
|
+
- 工厂可以是 async 的。解析时会 `await` 工厂,依赖方拿到的永远是值,不是 Promise。
|
|
91
|
+
- `options.mode` 默认是 `"singleton"`,见下文的[生命周期模式](#生命周期模式)。
|
|
92
|
+
|
|
93
|
+
### ripple.class
|
|
94
|
+
|
|
95
|
+
参数形式与 `ripple` 相同,只是把工厂换成 class,依赖会按顺序传给构造函数:
|
|
96
|
+
|
|
97
|
+
```ts
|
|
98
|
+
class Agent {
|
|
99
|
+
constructor(
|
|
100
|
+
readonly message: string,
|
|
101
|
+
readonly config: AgentOptions,
|
|
102
|
+
) {}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const AgentRipple = ripple.class("agent", [Message, AgentConfig], Agent);
|
|
106
|
+
const Logger = ripple.class(LoggerPebble, ConsoleLogger);
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
构造函数参数和依赖列表的类型会在编译期校验,依赖顺序写错会直接报错。
|
|
110
|
+
|
|
111
|
+
## 解析与类型
|
|
112
|
+
|
|
113
|
+
```ts
|
|
114
|
+
await app.resolve(Agent); // 类型来自 ripple
|
|
115
|
+
await app.resolve(AgentConfig); // 类型来自 pebble
|
|
116
|
+
await app.resolve("message"); // 类型来自 Registry,未声明时为 unknown
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
想让纯字符串 key 也带类型,可以扩展 `Registry`。扩展后字符串 key 会有自动补全,`ripple` 的返回值也会按这里的声明做校验:
|
|
120
|
+
|
|
121
|
+
```ts
|
|
122
|
+
declare module "@cyrenex/pondi" {
|
|
123
|
+
interface Registry {
|
|
124
|
+
message: string;
|
|
125
|
+
"agent:config": AgentOptions;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## 解析规则
|
|
131
|
+
|
|
132
|
+
解析一个 token 时,按以下顺序查找实现:
|
|
133
|
+
|
|
134
|
+
1. 当前容器及其所有父容器中通过 `use` 注册的实现,层级最近的优先。
|
|
135
|
+
2. 如果 token 本身是一个 ripple,就使用它自带的默认实现。
|
|
136
|
+
3. 都没有就抛出 `MISSING_PROVIDER`,错误里带有完整的依赖路径,例如 `agent -> agent:config`。
|
|
137
|
+
|
|
138
|
+
所以 ripple 不需要事先 `use` 就能直接 resolve;pebble 和纯字符串则必须有某个容器提供实现。
|
|
139
|
+
|
|
140
|
+
## 注册与覆盖
|
|
141
|
+
|
|
142
|
+
### use
|
|
143
|
+
|
|
144
|
+
```ts
|
|
145
|
+
app.use(Message, agentConfigPlugin(opts), [PluginA, PluginB]); // 也接受数组,方便插件一次返回多个 ripple
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
- 同一个 ripple 对象重复 `use` 不会有任何效果。
|
|
149
|
+
- 同一个 key 注册不同的实现,会抛出 `DUPLICATE_PROVIDER`。
|
|
150
|
+
- 一个 key 被 resolve 过之后,就不能在当前容器及其祖先容器中再注册,否则抛出 `PROVIDER_LOCKED`。这是为了避免新旧实例同时存在。
|
|
151
|
+
|
|
152
|
+
### fork
|
|
153
|
+
|
|
154
|
+
覆盖实现只有一种方式:创建子容器,在子容器里注册同名 key。
|
|
155
|
+
|
|
156
|
+
```ts
|
|
157
|
+
await using test = app.fork().use(ripple("message", () => "mock"));
|
|
158
|
+
|
|
159
|
+
await test.resolve(Agent); // 使用 mock 版本的 message
|
|
160
|
+
await app.resolve(Agent); // 父容器不受影响
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
子容器中,**依赖被覆盖到的 singleton 会重新创建**,没被影响的 singleton 仍然和父容器共享同一个实例。具体来说,一个 singleton 会缓存在"它自身的实现以及所有传递依赖的实现"当中注册位置最深的那个容器里。
|
|
164
|
+
|
|
165
|
+
适用场景:测试 mock、请求级上下文、多租户、替换插件中的部分实现。
|
|
166
|
+
|
|
167
|
+
## 生命周期模式
|
|
168
|
+
|
|
169
|
+
| mode | 实例缓存位置 | 由谁释放 |
|
|
170
|
+
| ------------------- | ---------------------------- | -------------- |
|
|
171
|
+
| `singleton`(默认) | 按上面的规则自动确定的容器 | 缓存它的容器 |
|
|
172
|
+
| `scoped` | 发起解析的容器,每个容器一份 | 该容器 |
|
|
173
|
+
| `transient` | 不缓存,每次解析都新建 | 发起解析的容器 |
|
|
174
|
+
|
|
175
|
+
- singleton 直接或间接依赖 scoped 时会抛出 `CAPTIVE_DEPENDENCY`。
|
|
176
|
+
- 同一个 singleton 被并发解析时,工厂只会执行一次。
|
|
177
|
+
- 工厂或初始化失败的结果不会被缓存,下次解析会重试。
|
|
178
|
+
|
|
179
|
+
## 异步初始化与资源释放
|
|
180
|
+
|
|
181
|
+
```ts
|
|
182
|
+
import { asyncDispose, dispose, init } from "@cyrenex/pondi";
|
|
183
|
+
|
|
184
|
+
class Db {
|
|
185
|
+
#conn!: Connection;
|
|
186
|
+
constructor(readonly config: DbConfig) {}
|
|
187
|
+
|
|
188
|
+
async [init]() {
|
|
189
|
+
this.#conn = await connect(this.config.url);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async [asyncDispose]() {
|
|
193
|
+
await this.#conn.close();
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
- **`init`**:实例创建后,容器会先 `await value[init]()`,完成后才把实例交给依赖方、写入缓存。它用来做构造函数里做不了的异步初始化。如果 `init` 抛错,容器会先释放这个实例,再抛出原错误。
|
|
199
|
+
- **`asyncDispose` / `dispose`**:就是标准的 `Symbol.asyncDispose` / `Symbol.dispose`。运行时不支持时会退回 `Symbol.for(...)`,可以直接配合 `await using` / `using` 使用。
|
|
200
|
+
- 以上机制对 `ripple` 工厂返回的对象和 `ripple.class` 创建的实例都有效。
|
|
201
|
+
|
|
202
|
+
释放容器时可以用 `await using`,也可以调用 `await app.dispose()`。释放顺序是:
|
|
203
|
+
|
|
204
|
+
1. 先释放所有子容器;
|
|
205
|
+
2. 再按创建顺序的逆序,释放当前容器持有的实例,也就是先释放依赖方,再释放被依赖的实例;
|
|
206
|
+
3. 多个释放错误会合并成一个 `AggregateError` 抛出。
|
|
207
|
+
|
|
208
|
+
容器释放后,再调用 `resolve`、`use` 或 `fork` 都会抛出 `DISPOSED`。
|
|
209
|
+
|
|
210
|
+
## 错误
|
|
211
|
+
|
|
212
|
+
所有容器错误都是 `PondError`,带有 `code` 和依赖路径 `path`:
|
|
213
|
+
|
|
214
|
+
| code | 触发条件 |
|
|
215
|
+
| --------------------- | ----------------------------------------- |
|
|
216
|
+
| `MISSING_PROVIDER` | 找不到 key 的实现 |
|
|
217
|
+
| `DUPLICATE_PROVIDER` | 同一个容器里,同一个 key 注册了不同的实现 |
|
|
218
|
+
| `PROVIDER_LOCKED` | key 已经被 resolve 过,又试图注册新的实现 |
|
|
219
|
+
| `CIRCULAR_DEPENDENCY` | 存在循环依赖 |
|
|
220
|
+
| `CAPTIVE_DEPENDENCY` | singleton 直接或间接依赖了 scoped |
|
|
221
|
+
| `DISPOSED` | 容器已释放 |
|
|
222
|
+
|
|
223
|
+
```ts
|
|
224
|
+
try {
|
|
225
|
+
await app.resolve(Agent);
|
|
226
|
+
} catch (error) {
|
|
227
|
+
if (error instanceof PondError && error.code === "MISSING_PROVIDER") {
|
|
228
|
+
console.error(error.path); // ["agent", "agent:config"]
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
## 开发
|
|
234
|
+
|
|
235
|
+
```bash
|
|
236
|
+
vp install # 安装依赖
|
|
237
|
+
vp test # 运行测试
|
|
238
|
+
vp check # 格式、lint、类型检查
|
|
239
|
+
vp pack # 构建
|
|
240
|
+
vp run changeset # 为下一次发布记录变更
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
## License
|
|
244
|
+
|
|
245
|
+
[MIT](./LICENSE)
|
|
246
|
+
|
|
247
|
+
## 特别感谢
|
|
248
|
+
|
|
249
|
+
感谢所有为 pondi 做出贡献的人 ❤️
|
|
250
|
+
|
|
251
|
+
<a href="https://github.com/cyrenex/pondi/graphs/contributors">
|
|
252
|
+
<img src="https://contrib.rocks/image?repo=cyrenex/pondi" />
|
|
253
|
+
</a>
|
|
254
|
+
|
|
255
|
+
## Star 历史
|
|
256
|
+
|
|
257
|
+
<a href="https://star-history.dera.page/#cyrenex/pondi&Date">
|
|
258
|
+
<picture>
|
|
259
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://star-history.dera.page/svg?repos=cyrenex/pondi&type=Date&theme=dark" />
|
|
260
|
+
<source media="(prefers-color-scheme: light)" srcset="https://star-history.dera.page/svg?repos=cyrenex/pondi&type=Date" />
|
|
261
|
+
<img alt="Star History Chart" src="https://star-history.dera.page/svg?repos=cyrenex/pondi&type=Date" />
|
|
262
|
+
</picture>
|
|
263
|
+
</a>
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The standard `Symbol.asyncDispose`, falling back to `Symbol.for("Symbol.asyncDispose")`
|
|
3
|
+
* on runtimes that lack it. Works with `await using`.
|
|
4
|
+
*/
|
|
5
|
+
export declare const asyncDispose: typeof Symbol.asyncDispose;
|
|
6
|
+
/**
|
|
7
|
+
* The standard `Symbol.dispose`, falling back to `Symbol.for("Symbol.dispose")`
|
|
8
|
+
* on runtimes that lack it. Works with `using`.
|
|
9
|
+
*/
|
|
10
|
+
export declare const dispose: typeof Symbol.dispose;
|
|
11
|
+
/**
|
|
12
|
+
* Lifecycle hook awaited by the container after an instance is created and before it is
|
|
13
|
+
* handed to dependents. Use it for async initialization a constructor cannot do.
|
|
14
|
+
* If it throws, the instance is disposed and the error propagates.
|
|
15
|
+
*/
|
|
16
|
+
export declare const init: unique symbol;
|
|
17
|
+
interface Initializable {
|
|
18
|
+
[init](): void | PromiseLike<void>;
|
|
19
|
+
}
|
|
20
|
+
declare const TYPE: unique symbol;
|
|
21
|
+
declare const RIPPLE: unique symbol;
|
|
22
|
+
/**
|
|
23
|
+
* Type registry for string keys. Augment it to get types for plain string tokens:
|
|
24
|
+
*
|
|
25
|
+
* ```ts
|
|
26
|
+
* declare module "@cyrenex/pondi" {
|
|
27
|
+
* interface Registry { message: string }
|
|
28
|
+
* }
|
|
29
|
+
* ```
|
|
30
|
+
*/
|
|
31
|
+
interface Registry {}
|
|
32
|
+
type Mode = "singleton" | "scoped" | "transient";
|
|
33
|
+
interface RippleOptions {
|
|
34
|
+
/** @default "singleton" */
|
|
35
|
+
mode?: Mode;
|
|
36
|
+
}
|
|
37
|
+
/** A typed contract identified by its string key. Has no implementation. */
|
|
38
|
+
interface Pebble<T> {
|
|
39
|
+
readonly key: string;
|
|
40
|
+
/** Phantom field carrying the value type; absent at runtime. */
|
|
41
|
+
readonly [TYPE]: T;
|
|
42
|
+
}
|
|
43
|
+
/** A pebble with an implementation. */
|
|
44
|
+
interface Ripple<T> extends Pebble<T> {
|
|
45
|
+
readonly deps: readonly Token[];
|
|
46
|
+
readonly factory: (...args: any[]) => T | PromiseLike<T>;
|
|
47
|
+
readonly mode: Mode;
|
|
48
|
+
readonly [RIPPLE]: true;
|
|
49
|
+
}
|
|
50
|
+
type Token = Pebble<any> | string;
|
|
51
|
+
/** String keys with autocomplete for registered ones, while still accepting any string. */
|
|
52
|
+
type TokenInput = Pebble<any> | (keyof Registry & string) | (string & {});
|
|
53
|
+
type KeyValue<K extends string> = K extends keyof Registry ? Registry[K] : unknown;
|
|
54
|
+
type Value<K> = K extends Pebble<infer T> ? T : K extends string ? KeyValue<K> : never;
|
|
55
|
+
type Values<D extends readonly Token[]> = { -readonly [I in keyof D]: Value<D[I]>; };
|
|
56
|
+
type Factory<D extends readonly Token[], T> = (...args: Values<D>) => T | PromiseLike<T>;
|
|
57
|
+
export declare function pebble<T>(key: string): Pebble<T>;
|
|
58
|
+
export declare function ripple<T>(pebble: Pebble<T>, factory: Factory<[], T>, options?: RippleOptions): Ripple<T>;
|
|
59
|
+
export declare namespace ripple {
|
|
60
|
+
export { rippleClass as class };
|
|
61
|
+
}
|
|
62
|
+
export declare function ripple<T, const D extends readonly Token[]>(pebble: Pebble<T>, deps: D, factory: Factory<D, T>, options?: RippleOptions): Ripple<T>;
|
|
63
|
+
export declare function ripple<K extends string, T extends KeyValue<K>>(key: K, factory: Factory<[], T>, options?: RippleOptions): Ripple<T>;
|
|
64
|
+
export declare function ripple<K extends string, const D extends readonly Token[], T extends KeyValue<K>>(key: K, deps: D, factory: Factory<D, T>, options?: RippleOptions): Ripple<T>;
|
|
65
|
+
type Constructor<D extends readonly Token[], T> = new (...args: Values<D>) => T;
|
|
66
|
+
declare function rippleClass<T>(pebble: Pebble<T>, cls: Constructor<[], T>, options?: RippleOptions): Ripple<T>;
|
|
67
|
+
declare function rippleClass<T, const D extends readonly Token[]>(pebble: Pebble<T>, deps: D, cls: Constructor<D, T>, options?: RippleOptions): Ripple<T>;
|
|
68
|
+
declare function rippleClass<K extends string, T extends KeyValue<K>>(key: K, cls: Constructor<[], T>, options?: RippleOptions): Ripple<T>;
|
|
69
|
+
declare function rippleClass<K extends string, const D extends readonly Token[], T extends KeyValue<K>>(key: K, deps: D, cls: Constructor<D, T>, options?: RippleOptions): Ripple<T>;
|
|
70
|
+
export declare class Pond implements AsyncDisposable {
|
|
71
|
+
#private;
|
|
72
|
+
/**
|
|
73
|
+
* Register providers in this container. Accepts ripples or arrays of ripples (e.g. from plugins).
|
|
74
|
+
* Registering the same ripple twice is a no-op; a different ripple for an existing key throws.
|
|
75
|
+
* Use `fork()` to shadow a provider from a parent container.
|
|
76
|
+
*/
|
|
77
|
+
use(...items: (Ripple<any> | readonly Ripple<any>[])[]): this;
|
|
78
|
+
/** Create a child container. Its `use()` may shadow providers of this container. */
|
|
79
|
+
fork(): Pond;
|
|
80
|
+
resolve<K extends TokenInput>(token: K): Promise<Value<K>>;
|
|
81
|
+
/** Dispose child containers, then owned instances in reverse creation order. */
|
|
82
|
+
[asyncDispose](): Promise<void>;
|
|
83
|
+
dispose(): Promise<void>;
|
|
84
|
+
}
|
|
85
|
+
type PondErrorCode = "DUPLICATE_PROVIDER" | "PROVIDER_LOCKED" | "MISSING_PROVIDER" | "CIRCULAR_DEPENDENCY" | "CAPTIVE_DEPENDENCY" | "DISPOSED";
|
|
86
|
+
export declare class PondError extends Error {
|
|
87
|
+
name: string;
|
|
88
|
+
readonly code: PondErrorCode;
|
|
89
|
+
/** Dependency path leading to the error, outermost first. */
|
|
90
|
+
readonly path: readonly string[];
|
|
91
|
+
constructor(code: PondErrorCode, message: string, path?: readonly string[]);
|
|
92
|
+
}
|
|
93
|
+
export type { Constructor, Factory, Initializable, Mode, Pebble, PondErrorCode, Registry, Ripple, RippleOptions, Token, TokenInput, Value, Values };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
// src/errors.ts
|
|
2
|
+
var PondError = class extends Error {
|
|
3
|
+
name = "PondError";
|
|
4
|
+
code;
|
|
5
|
+
/** Dependency path leading to the error, outermost first. */
|
|
6
|
+
path;
|
|
7
|
+
constructor(code, message, path = []) {
|
|
8
|
+
super(path.length > 0 ? `${message} (${path.join(" -> ")})` : message);
|
|
9
|
+
this.code = code;
|
|
10
|
+
this.path = path;
|
|
11
|
+
}
|
|
12
|
+
};
|
|
13
|
+
// src/lifecycle.ts
|
|
14
|
+
/**
|
|
15
|
+
* The standard `Symbol.asyncDispose`, falling back to `Symbol.for("Symbol.asyncDispose")`
|
|
16
|
+
* on runtimes that lack it. Works with `await using`.
|
|
17
|
+
*/
|
|
18
|
+
const asyncDispose = Symbol.asyncDispose ?? Symbol.for("Symbol.asyncDispose");
|
|
19
|
+
/**
|
|
20
|
+
* The standard `Symbol.dispose`, falling back to `Symbol.for("Symbol.dispose")`
|
|
21
|
+
* on runtimes that lack it. Works with `using`.
|
|
22
|
+
*/
|
|
23
|
+
const dispose = Symbol.dispose ?? Symbol.for("Symbol.dispose");
|
|
24
|
+
/**
|
|
25
|
+
* Lifecycle hook awaited by the container after an instance is created and before it is
|
|
26
|
+
* handed to dependents. Use it for async initialization a constructor cannot do.
|
|
27
|
+
* If it throws, the instance is disposed and the error propagates.
|
|
28
|
+
*/
|
|
29
|
+
const init = Symbol.for("@cyrenex/pondi.init");
|
|
30
|
+
function isObject(value) {
|
|
31
|
+
return (typeof value === "object" || typeof value === "function") && value !== null;
|
|
32
|
+
}
|
|
33
|
+
function isDisposable(value) {
|
|
34
|
+
return isObject(value) && (asyncDispose in value || dispose in value);
|
|
35
|
+
}
|
|
36
|
+
async function disposeValue(value) {
|
|
37
|
+
if (!isDisposable(value)) return;
|
|
38
|
+
if (asyncDispose in value) await value[asyncDispose]();
|
|
39
|
+
else value[dispose]();
|
|
40
|
+
}
|
|
41
|
+
async function initValue(value) {
|
|
42
|
+
if (!isObject(value) || typeof value[init] !== "function") return;
|
|
43
|
+
try {
|
|
44
|
+
await value[init]();
|
|
45
|
+
} catch (error) {
|
|
46
|
+
try {
|
|
47
|
+
await disposeValue(value);
|
|
48
|
+
} catch (disposeError) {
|
|
49
|
+
throw new AggregateError([error, disposeError], "Initialization and cleanup both failed");
|
|
50
|
+
}
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
// src/token.ts
|
|
55
|
+
const RIPPLE = Symbol("pondi.ripple");
|
|
56
|
+
function pebble(key) {
|
|
57
|
+
return Object.freeze({ key });
|
|
58
|
+
}
|
|
59
|
+
function ripple(target, depsOrFactory, factoryOrOptions, maybeOptions) {
|
|
60
|
+
const key = keyOf(target);
|
|
61
|
+
let deps;
|
|
62
|
+
let factory;
|
|
63
|
+
let options;
|
|
64
|
+
if (typeof depsOrFactory === "function") {
|
|
65
|
+
deps = [];
|
|
66
|
+
factory = depsOrFactory;
|
|
67
|
+
options = factoryOrOptions;
|
|
68
|
+
} else {
|
|
69
|
+
deps = depsOrFactory;
|
|
70
|
+
factory = factoryOrOptions;
|
|
71
|
+
options = maybeOptions;
|
|
72
|
+
}
|
|
73
|
+
if (typeof factory !== "function") throw new TypeError(`ripple("${key}"): factory must be a function`);
|
|
74
|
+
const mode = options?.mode ?? "singleton";
|
|
75
|
+
if (mode !== "singleton" && mode !== "scoped" && mode !== "transient") throw new TypeError(`ripple("${key}"): unknown mode "${String(mode)}"`);
|
|
76
|
+
return Object.freeze({
|
|
77
|
+
key,
|
|
78
|
+
deps: Object.freeze([...deps]),
|
|
79
|
+
factory,
|
|
80
|
+
mode,
|
|
81
|
+
[RIPPLE]: true
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
function rippleClass(target, depsOrClass, classOrOptions, maybeOptions) {
|
|
85
|
+
const [deps, cls, options] = Array.isArray(depsOrClass) ? [
|
|
86
|
+
depsOrClass,
|
|
87
|
+
classOrOptions,
|
|
88
|
+
maybeOptions
|
|
89
|
+
] : [
|
|
90
|
+
[],
|
|
91
|
+
depsOrClass,
|
|
92
|
+
classOrOptions
|
|
93
|
+
];
|
|
94
|
+
if (typeof cls !== "function") throw new TypeError(`ripple.class("${keyOf(target)}"): expected a class`);
|
|
95
|
+
return ripple(target, deps, (...args) => new cls(...args), options);
|
|
96
|
+
}
|
|
97
|
+
/** Like `ripple`, but instantiates a class with the resolved dependencies as constructor arguments. */
|
|
98
|
+
ripple.class = rippleClass;
|
|
99
|
+
function isRipple(value) {
|
|
100
|
+
return typeof value === "object" && value !== null && RIPPLE in value;
|
|
101
|
+
}
|
|
102
|
+
function keyOf(token) {
|
|
103
|
+
return typeof token === "string" ? token : token.key;
|
|
104
|
+
}
|
|
105
|
+
// src/container.ts
|
|
106
|
+
var Pond = class Pond {
|
|
107
|
+
#parent;
|
|
108
|
+
/** Containers from root to this one. */
|
|
109
|
+
#chain = [this];
|
|
110
|
+
#children = /* @__PURE__ */ new Set();
|
|
111
|
+
#registry = /* @__PURE__ */ new Map();
|
|
112
|
+
/** Keys whose provider resolution is fixed because something resolved them through this container. */
|
|
113
|
+
#locked = /* @__PURE__ */ new Set();
|
|
114
|
+
#analysis = /* @__PURE__ */ new Map();
|
|
115
|
+
#cache = /* @__PURE__ */ new Map();
|
|
116
|
+
/** Created instances in creation order, disposed in reverse. */
|
|
117
|
+
#instances = [];
|
|
118
|
+
#disposing;
|
|
119
|
+
get #depth() {
|
|
120
|
+
return this.#chain.length - 1;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Register providers in this container. Accepts ripples or arrays of ripples (e.g. from plugins).
|
|
124
|
+
* Registering the same ripple twice is a no-op; a different ripple for an existing key throws.
|
|
125
|
+
* Use `fork()` to shadow a provider from a parent container.
|
|
126
|
+
*/
|
|
127
|
+
use(...items) {
|
|
128
|
+
this.#assertAlive();
|
|
129
|
+
for (const item of items.flat()) {
|
|
130
|
+
if (!isRipple(item)) throw new TypeError("Pond.use() expects ripples");
|
|
131
|
+
this.#register(item);
|
|
132
|
+
}
|
|
133
|
+
return this;
|
|
134
|
+
}
|
|
135
|
+
/** Create a child container. Its `use()` may shadow providers of this container. */
|
|
136
|
+
fork() {
|
|
137
|
+
this.#assertAlive();
|
|
138
|
+
const child = new Pond();
|
|
139
|
+
child.#parent = this;
|
|
140
|
+
child.#chain = [...this.#chain, child];
|
|
141
|
+
this.#children.add(child);
|
|
142
|
+
return child;
|
|
143
|
+
}
|
|
144
|
+
resolve(token) {
|
|
145
|
+
try {
|
|
146
|
+
return this.#resolve(token);
|
|
147
|
+
} catch (error) {
|
|
148
|
+
return Promise.reject(error);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
/** Dispose child containers, then owned instances in reverse creation order. */
|
|
152
|
+
[asyncDispose]() {
|
|
153
|
+
this.#disposing ??= this.#dispose();
|
|
154
|
+
return this.#disposing;
|
|
155
|
+
}
|
|
156
|
+
dispose() {
|
|
157
|
+
return this[asyncDispose]();
|
|
158
|
+
}
|
|
159
|
+
#register(provider) {
|
|
160
|
+
const existing = this.#registry.get(provider.key);
|
|
161
|
+
if (existing === provider) return;
|
|
162
|
+
if (this.#locked.has(provider.key)) throw new PondError("PROVIDER_LOCKED", `"${provider.key}" has already been resolved through this container and can no longer be provided here`);
|
|
163
|
+
if (existing) throw new PondError("DUPLICATE_PROVIDER", `A different provider for "${provider.key}" is already registered in this container; use fork() to shadow it`);
|
|
164
|
+
this.#registry.set(provider.key, provider);
|
|
165
|
+
}
|
|
166
|
+
#lookup(token, path) {
|
|
167
|
+
const key = keyOf(token);
|
|
168
|
+
for (let depth = this.#depth; depth >= 0; depth--) {
|
|
169
|
+
const provider = this.#chain[depth].#registry.get(key);
|
|
170
|
+
if (provider) return {
|
|
171
|
+
provider,
|
|
172
|
+
depth
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
if (isRipple(token)) {
|
|
176
|
+
this.#chain[0].#registry.set(key, token);
|
|
177
|
+
return {
|
|
178
|
+
provider: token,
|
|
179
|
+
depth: 0
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
throw new PondError("MISSING_PROVIDER", `No provider for "${key}"`, [...path, key]);
|
|
183
|
+
}
|
|
184
|
+
/** Statically walk the dependency graph to find providers, cycles, and singleton homes. */
|
|
185
|
+
#analyze(token, path) {
|
|
186
|
+
const key = keyOf(token);
|
|
187
|
+
const memo = this.#analysis.get(key);
|
|
188
|
+
if (memo) return memo;
|
|
189
|
+
if (path.includes(key)) throw new PondError("CIRCULAR_DEPENDENCY", `Circular dependency on "${key}"`, [...path, key]);
|
|
190
|
+
const { provider, depth } = this.#lookup(token, path);
|
|
191
|
+
path.push(key);
|
|
192
|
+
const deps = provider.deps.map((dep) => this.#analyze(dep, path));
|
|
193
|
+
path.pop();
|
|
194
|
+
let info;
|
|
195
|
+
const mode = provider.mode;
|
|
196
|
+
if (mode === "scoped") info = {
|
|
197
|
+
provider,
|
|
198
|
+
home: this.#depth,
|
|
199
|
+
scoped: true
|
|
200
|
+
};
|
|
201
|
+
else {
|
|
202
|
+
const scopedDep = deps.find((d) => d.scoped);
|
|
203
|
+
if (mode === "singleton" && scopedDep) throw new PondError("CAPTIVE_DEPENDENCY", `Singleton "${key}" cannot depend on scoped "${scopedDep.provider.key}"`, [...path, key]);
|
|
204
|
+
info = {
|
|
205
|
+
provider,
|
|
206
|
+
home: Math.max(depth, ...deps.map((d) => d.home)),
|
|
207
|
+
scoped: scopedDep !== void 0
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
for (const c of this.#chain) c.#locked.add(key);
|
|
211
|
+
this.#analysis.set(key, info);
|
|
212
|
+
return info;
|
|
213
|
+
}
|
|
214
|
+
#resolve(token) {
|
|
215
|
+
this.#assertAlive();
|
|
216
|
+
const { provider, home } = this.#analyze(token, []);
|
|
217
|
+
if (provider.mode === "transient") return this.#instantiate(provider);
|
|
218
|
+
const target = provider.mode === "singleton" ? this.#chain[home] : this;
|
|
219
|
+
if (target !== this) return target.#resolve(token);
|
|
220
|
+
const cached = this.#cache.get(provider.key);
|
|
221
|
+
if (cached) return cached;
|
|
222
|
+
const pending = this.#instantiate(provider);
|
|
223
|
+
this.#cache.set(provider.key, pending);
|
|
224
|
+
pending.catch(() => {
|
|
225
|
+
if (this.#cache.get(provider.key) === pending) this.#cache.delete(provider.key);
|
|
226
|
+
});
|
|
227
|
+
return pending;
|
|
228
|
+
}
|
|
229
|
+
async #instantiate(provider) {
|
|
230
|
+
const args = await Promise.all(provider.deps.map((dep) => this.#resolve(dep)));
|
|
231
|
+
const value = await provider.factory(...args);
|
|
232
|
+
await initValue(value);
|
|
233
|
+
if (this.#disposing) {
|
|
234
|
+
await disposeValue(value);
|
|
235
|
+
throw new PondError("DISPOSED", `Container was disposed while creating "${provider.key}"`);
|
|
236
|
+
}
|
|
237
|
+
if (isDisposable(value)) this.#instances.push(value);
|
|
238
|
+
return value;
|
|
239
|
+
}
|
|
240
|
+
async #dispose() {
|
|
241
|
+
const errors = [];
|
|
242
|
+
for (const child of [...this.#children].reverse()) try {
|
|
243
|
+
await child.dispose();
|
|
244
|
+
} catch (error) {
|
|
245
|
+
errors.push(error);
|
|
246
|
+
}
|
|
247
|
+
await Promise.allSettled(this.#cache.values());
|
|
248
|
+
for (const value of this.#instances.reverse()) try {
|
|
249
|
+
await disposeValue(value);
|
|
250
|
+
} catch (error) {
|
|
251
|
+
errors.push(error);
|
|
252
|
+
}
|
|
253
|
+
this.#instances.length = 0;
|
|
254
|
+
this.#cache.clear();
|
|
255
|
+
this.#analysis.clear();
|
|
256
|
+
if (this.#parent) this.#parent.#children.delete(this);
|
|
257
|
+
if (errors.length === 1) throw errors[0];
|
|
258
|
+
if (errors.length > 1) throw new AggregateError(errors, "Failed to dispose Pond container");
|
|
259
|
+
}
|
|
260
|
+
#assertAlive() {
|
|
261
|
+
if (this.#disposing) throw new PondError("DISPOSED", "Container has been disposed");
|
|
262
|
+
}
|
|
263
|
+
};
|
|
264
|
+
export { Pond, PondError, asyncDispose, dispose, init, pebble, ripple };
|
package/package.json
CHANGED
|
@@ -1,6 +1,49 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cyrenex/pondi",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Type-safe, string-keyed dependency injection with async factories and scoped disposal.",
|
|
5
|
+
"homepage": "https://github.com/cyrenex/pondi#readme",
|
|
6
|
+
"bugs": {
|
|
7
|
+
"url": "https://github.com/cyrenex/pondi/issues"
|
|
8
|
+
},
|
|
9
|
+
"license": "MIT",
|
|
10
|
+
"author": "byc <bycrxemail@gmail.com>",
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "git+https://github.com/cyrenex/pondi.git"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist"
|
|
17
|
+
],
|
|
18
|
+
"type": "module",
|
|
19
|
+
"exports": {
|
|
20
|
+
".": "./dist/index.mjs",
|
|
21
|
+
"./package.json": "./package.json"
|
|
22
|
+
},
|
|
23
|
+
"publishConfig": {
|
|
24
|
+
"access": "public",
|
|
25
|
+
"provenance": true
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@changesets/changelog-github": "^1.0.1",
|
|
29
|
+
"@changesets/cli": "^3.0.3",
|
|
30
|
+
"@types/node": "^26.6.5",
|
|
31
|
+
"typescript": "^7.0.2",
|
|
32
|
+
"vite-plus": "1.4.0"
|
|
33
|
+
},
|
|
34
|
+
"devEngines": {
|
|
35
|
+
"packageManager": {
|
|
36
|
+
"name": "pnpm",
|
|
37
|
+
"version": "12.16.1",
|
|
38
|
+
"onFail": "download"
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
"scripts": {
|
|
42
|
+
"build": "vp pack",
|
|
43
|
+
"dev": "vp pack --watch",
|
|
44
|
+
"test": "vp test",
|
|
45
|
+
"check": "vp check",
|
|
46
|
+
"changeset": "changeset",
|
|
47
|
+
"release": "changeset publish"
|
|
48
|
+
}
|
|
6
49
|
}
|