ripple-di 1.0.0 → 1.0.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 +240 -121
- package/dist/index.mjs +39 -4
- package/package.json +7 -4
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Ripple DI
|
|
2
2
|
|
|
3
|
-
Scoped dependency injection for TypeScript
|
|
3
|
+
Scoped dependency injection for TypeScript with automatic dependency tracking, lifecycle-aware cleanup, and no container lookups in application code.
|
|
4
4
|
|
|
5
5
|
## Example
|
|
6
6
|
|
|
@@ -29,7 +29,7 @@ Now replace only the configuration for one operation:
|
|
|
29
29
|
import { provide, withOverrides } from "ripple-di"
|
|
30
30
|
|
|
31
31
|
const users = await withOverrides(
|
|
32
|
-
|
|
32
|
+
provide(useConfig, { databaseUrl: testDatabaseUrl }),
|
|
33
33
|
() => loadUsers(),
|
|
34
34
|
)
|
|
35
35
|
```
|
|
@@ -37,7 +37,13 @@ const users = await withOverrides(
|
|
|
37
37
|
`useDb` reads `useConfig()`, so Ripple DI builds a separate database for the test configuration and loads the users through it.
|
|
38
38
|
The production database and everything unrelated keep the instances they already had, and the temporary database is closed when the callback finishes.
|
|
39
39
|
|
|
40
|
-
This is the ripple: override one input, and only the values
|
|
40
|
+
This is the ripple: override one input, and only the values derived from it are rebuilt.
|
|
41
|
+
|
|
42
|
+
## Design trade-off
|
|
43
|
+
|
|
44
|
+
Ripple DI is an ambient service locator by design: code imports a dependency and calls it instead of receiving it as an argument.
|
|
45
|
+
|
|
46
|
+
This makes dependencies convenient to use and override, but also makes them less explicit: looking at a function's signature does not tell you which Ripple DI dependencies it reads.
|
|
41
47
|
|
|
42
48
|
## Install
|
|
43
49
|
|
|
@@ -48,59 +54,22 @@ npm install ripple-di
|
|
|
48
54
|
Ripple DI needs `node:async_hooks`, so it runs on Node.js 18 or newer, Bun, and Deno with Node compatibility, but not in browsers.
|
|
49
55
|
The package is published as ESM.
|
|
50
56
|
|
|
51
|
-
## Quick start
|
|
52
|
-
|
|
53
|
-
Define each shared input and service once, next to the code that owns it.
|
|
54
|
-
Factories are lazy: they run the first time something reads the value.
|
|
55
|
-
|
|
56
|
-
```ts
|
|
57
|
-
// config.ts
|
|
58
|
-
import { defineDependency } from "ripple-di"
|
|
59
|
-
|
|
60
|
-
export const useConfig = defineDependency(() => ({
|
|
61
|
-
databaseUrl: process.env.DATABASE_URL,
|
|
62
|
-
}))
|
|
63
|
-
```
|
|
64
|
-
|
|
65
|
-
```ts
|
|
66
|
-
// db.ts
|
|
67
|
-
import { defineDependency } from "ripple-di"
|
|
68
|
-
|
|
69
|
-
import { useConfig } from "./config"
|
|
70
|
-
|
|
71
|
-
export const useDb = defineDependency(
|
|
72
|
-
() => createDb(useConfig().databaseUrl),
|
|
73
|
-
{ dispose: db => db.close() },
|
|
74
|
-
)
|
|
75
|
-
```
|
|
76
|
-
|
|
77
|
-
Anything that needs the database imports it and calls it.
|
|
78
|
-
|
|
79
|
-
```ts
|
|
80
|
-
import { useDb } from "./db"
|
|
81
|
-
|
|
82
|
-
export async function loadUsers() {
|
|
83
|
-
return useDb().query("select * from users")
|
|
84
|
-
}
|
|
85
|
-
```
|
|
86
|
-
|
|
87
|
-
The return type of `useDb()` is inferred from `createDb`.
|
|
88
|
-
|
|
89
57
|
## Which function do I call?
|
|
90
58
|
|
|
91
|
-
| You want to
|
|
92
|
-
|
|
|
93
|
-
| Define an input, derived value, or service
|
|
94
|
-
| Supply the
|
|
95
|
-
| Replace values for one callback
|
|
96
|
-
|
|
|
97
|
-
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
| Shut everything down | `dispose`
|
|
59
|
+
| You want to | Call
|
|
60
|
+
| --------------------------------------------- | --------------------------------------------------
|
|
61
|
+
| Define an input, derived value, or service | `defineDependency`
|
|
62
|
+
| Supply the application's values at startup | `install` with `provide` or `provideFactory`
|
|
63
|
+
| Replace values for one callback | `withOverrides` with `provide` or `provideFactory`
|
|
64
|
+
| Keep one scope open across several operations | `createScope`
|
|
65
|
+
| Shut everything down | `dispose`
|
|
66
|
+
|
|
67
|
+
`createValueOverride` and `createOverrideRunner` in [Advanced usage](#advanced-usage) turn overrides you write repeatedly into reusable helpers.
|
|
101
68
|
|
|
102
69
|
## Define dependencies
|
|
103
70
|
|
|
71
|
+
Define each shared input and service once, in the module that owns it, and import it wherever it is used.
|
|
72
|
+
|
|
104
73
|
Call `defineDependency<T>()` without a factory when a value must come from the application, request, or task boundary.
|
|
105
74
|
|
|
106
75
|
```ts
|
|
@@ -109,7 +78,7 @@ import { defineDependency, provide, withOverrides } from "ripple-di"
|
|
|
109
78
|
const useTenant = defineDependency<Tenant>()
|
|
110
79
|
|
|
111
80
|
await withOverrides(
|
|
112
|
-
|
|
81
|
+
provide(useTenant, tenant),
|
|
113
82
|
() => processRequest(),
|
|
114
83
|
)
|
|
115
84
|
```
|
|
@@ -117,29 +86,57 @@ await withOverrides(
|
|
|
117
86
|
Reading it without a provider throws `MissingProviderError`.
|
|
118
87
|
Pass a factory when the dependency has a built-in value.
|
|
119
88
|
The factory runs lazily, and calls to other dependencies inside it are tracked.
|
|
120
|
-
A function is always
|
|
89
|
+
A function in the first argument is always the factory, so a dependency whose value is itself a function wraps it: `defineDependency(() => handler)`.
|
|
121
90
|
|
|
122
91
|
```ts
|
|
123
92
|
const useClock = defineDependency(() => systemClock)
|
|
124
93
|
const usePublicUrl = defineDependency(() => createPublicUrl(useConfig()))
|
|
125
94
|
```
|
|
126
95
|
|
|
127
|
-
The result is cached and
|
|
128
|
-
|
|
96
|
+
The result is cached, and a scope that overrides one of the dependencies it read gets a separate result.
|
|
97
|
+
Its type comes from the factory, so `useDb()` returns whatever `createDb` returns.
|
|
98
|
+
Add a `dispose` callback when values owned by Ripple DI need cleanup, as shown by `useDb` in the example above.
|
|
99
|
+
|
|
100
|
+
A factory can read dependencies, but cannot create, enter, close, or retire scopes and installations in its own runtime; misuse throws `FactoryScopeOperationError`.
|
|
101
|
+
|
|
102
|
+
### Promises and thenables
|
|
103
|
+
|
|
104
|
+
A factory returns the value itself, synchronously; disposers may be asynchronous.
|
|
105
|
+
|
|
106
|
+
```ts
|
|
107
|
+
import { asValue, defineDependency } from "ripple-di"
|
|
108
|
+
|
|
109
|
+
// The usual case: the factory returns the value.
|
|
110
|
+
defineDependency(() => createSessionStore())
|
|
111
|
+
|
|
112
|
+
// Reading this throws AsyncFactoryError: reads made after an await are not tracked.
|
|
113
|
+
defineDependency(async () => loadSession())
|
|
114
|
+
|
|
115
|
+
// The promise itself is the cached dependency value.
|
|
116
|
+
defineDependency(() => asValue(loadSession()))
|
|
117
|
+
|
|
118
|
+
// A query builder implements `then` but is an ordinary value.
|
|
119
|
+
defineDependency(() => selectSessions())
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
A factory is rejected only when it returns a native `Promise` object, so a value that merely implements `then` is stored as it is.
|
|
123
|
+
Wrap the result in `asValue` when the promise itself is the value the dependency holds.
|
|
124
|
+
|
|
125
|
+
`asValue` marks the promise as the value and does nothing else.
|
|
126
|
+
The dependencies its asynchronous work reads after an `await` are still untracked, so a promise cached for the whole application can end up built from the temporary values of whichever scope started it.
|
|
129
127
|
|
|
130
128
|
## Override dependencies
|
|
131
129
|
|
|
132
130
|
`withOverrides` runs a callback with temporary values and cleans up whatever it created for that callback.
|
|
131
|
+
Pass one `provide` or `provideFactory` directly.
|
|
133
132
|
|
|
134
133
|
```ts
|
|
135
134
|
import { provide, withOverrides } from "ripple-di"
|
|
136
135
|
|
|
137
136
|
const users = await withOverrides(
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
}),
|
|
142
|
-
],
|
|
137
|
+
provide(useConfig, {
|
|
138
|
+
databaseUrl: "postgres://localhost/test",
|
|
139
|
+
}),
|
|
143
140
|
() => loadUsers(),
|
|
144
141
|
)
|
|
145
142
|
```
|
|
@@ -159,43 +156,81 @@ await withOverrides(
|
|
|
159
156
|
)
|
|
160
157
|
```
|
|
161
158
|
|
|
162
|
-
For one dependency, you can omit the array:
|
|
163
|
-
|
|
164
|
-
```ts
|
|
165
|
-
await withOverrides(
|
|
166
|
-
provide(useConfig, testConfig),
|
|
167
|
-
() => loadUsers(),
|
|
168
|
-
)
|
|
169
|
-
```
|
|
170
|
-
|
|
171
159
|
Overrides survive `await` and stay isolated between parallel callbacks.
|
|
172
160
|
|
|
173
161
|
```ts
|
|
174
162
|
const [leftUsers, rightUsers] = await Promise.all([
|
|
175
|
-
withOverrides(
|
|
176
|
-
withOverrides(
|
|
163
|
+
withOverrides(provide(useConfig, leftConfig), () => loadUsers()),
|
|
164
|
+
withOverrides(provide(useConfig, rightConfig), () => loadUsers()),
|
|
177
165
|
])
|
|
178
166
|
```
|
|
179
167
|
|
|
180
|
-
###
|
|
168
|
+
### Awaitable callback results
|
|
169
|
+
|
|
170
|
+
`withOverrides`, and every helper built on it, awaits what its callback returns before closing the temporary scope, so returning a query builder from one of them runs the query.
|
|
171
|
+
Use such a value inside the callback, and use `createScope` when it has to outlive the callback.
|
|
172
|
+
Wrapping the value in an object prevents it from being awaited, but the temporary scope still closes before the caller receives it.
|
|
173
|
+
Anything that scope owned has already been cleaned up.
|
|
174
|
+
|
|
175
|
+
## Where a value belongs
|
|
181
176
|
|
|
182
|
-
|
|
177
|
+
The same tracked dependency calls that decide when a factory result must be rebuilt also decide which lifecycle owns it and invokes its disposer.
|
|
178
|
+
A factory-created value belongs to the innermost scope that supplied either its factory or one of the dependencies it read.
|
|
179
|
+
When its factory and everything it read belong to the application, the value belongs to the application too, and closing a child scope leaves it alone.
|
|
180
|
+
|
|
181
|
+
**Reading a dependency inside a scope does not make its value scope-local.**
|
|
183
182
|
|
|
184
183
|
```ts
|
|
185
|
-
|
|
184
|
+
// Wrong: every request shares this one transaction.
|
|
185
|
+
const useTransaction = defineDependency(
|
|
186
|
+
() => useDb().transaction(),
|
|
187
|
+
{ dispose: tx => tx.rollback() },
|
|
188
|
+
)
|
|
189
|
+
```
|
|
186
190
|
|
|
187
|
-
|
|
191
|
+
`useDb` belongs to the application, so a transaction built from it belongs to the application too.
|
|
192
|
+
Every request reads the same transaction, and it is rolled back at shutdown rather than when the request ends.
|
|
188
193
|
|
|
189
|
-
|
|
194
|
+
For a value that belongs to one operation, define the dependency without a built-in factory.
|
|
195
|
+
Provide its factory when you create the scope of that operation:
|
|
196
|
+
|
|
197
|
+
```ts
|
|
198
|
+
const useTransaction = defineDependency<Transaction>({
|
|
199
|
+
dispose: tx => tx.rollback(),
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
await withOverrides(
|
|
203
|
+
provideFactory(useTransaction, () => useDb().transaction()),
|
|
204
|
+
() => processRequest(),
|
|
205
|
+
)
|
|
190
206
|
```
|
|
191
207
|
|
|
192
|
-
Each call
|
|
193
|
-
|
|
208
|
+
Each call now builds its own transaction, still lazily, and the scope rolls it back when the callback finishes.
|
|
209
|
+
Reading `useTransaction()` outside such a scope throws `MissingProviderError` instead of quietly sharing one.
|
|
210
|
+
|
|
211
|
+
A value also becomes scope-local through what it reads: a factory that reads a dependency the scope provides — a request context, a tenant, a fixed clock — is rebuilt in that scope and cleaned up with it.
|
|
212
|
+
|
|
213
|
+
An installation follows the same rule: a value belongs to it when the installation supplied its factory or one of the dependencies the factory read.
|
|
194
214
|
|
|
195
215
|
```ts
|
|
196
|
-
const
|
|
216
|
+
const useBuiltInConfig = defineDependency(() => rootConfig)
|
|
217
|
+
const useRootPool = defineDependency(() => createPool(useBuiltInConfig()), {
|
|
218
|
+
dispose: pool => pool.end(),
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
const useInstalledConfig = defineDependency<DatabaseConfig>()
|
|
222
|
+
const useInstalledPool = defineDependency(() => createPool(useInstalledConfig()), {
|
|
223
|
+
dispose: pool => pool.end(),
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
const installation = install(provide(useInstalledConfig, installedConfig))
|
|
227
|
+
useRootPool()
|
|
228
|
+
useInstalledPool()
|
|
229
|
+
await installation.close() // Before installing replacement providers.
|
|
197
230
|
```
|
|
198
231
|
|
|
232
|
+
The installed pool closes with the installation, while the root pool remains cached until `dispose()` because its factory read only built-in dependencies.
|
|
233
|
+
|
|
199
234
|
## Wire the application at startup
|
|
200
235
|
|
|
201
236
|
Some dependencies get their real value only when the application starts.
|
|
@@ -211,11 +246,11 @@ export const useTenantResolver = defineDependency<TenantResolver>()
|
|
|
211
246
|
|
|
212
247
|
```ts
|
|
213
248
|
// startup.ts
|
|
214
|
-
import { install, provide } from "ripple-di"
|
|
249
|
+
import { dispose, install, provide } from "ripple-di"
|
|
215
250
|
|
|
216
251
|
import { useTenantResolver, useWebConfig } from "./dependencies"
|
|
217
252
|
|
|
218
|
-
|
|
253
|
+
install([
|
|
219
254
|
provide(useWebConfig, config),
|
|
220
255
|
provide(useTenantResolver, resolveTenant),
|
|
221
256
|
])
|
|
@@ -223,20 +258,25 @@ const installation = install([
|
|
|
223
258
|
try {
|
|
224
259
|
await runApplication()
|
|
225
260
|
} finally {
|
|
226
|
-
await
|
|
261
|
+
await dispose()
|
|
227
262
|
}
|
|
228
263
|
```
|
|
229
264
|
|
|
230
265
|
Installed providers are the fallback everywhere: request handlers, background jobs, tests, and any other code running outside a scope.
|
|
231
266
|
Nothing is resolved eagerly, and a scoped override still wins over an installed provider.
|
|
232
|
-
Closing
|
|
267
|
+
Closing an installation removes its providers and cleans up values whose factory or tracked dependencies belong to it.
|
|
268
|
+
It keeps the runtime usable, so application-owned values remain cached for later installations.
|
|
269
|
+
Use `installation.close()` before a controlled replacement; use `dispose()` to shut the application down.
|
|
233
270
|
|
|
234
271
|
- Installing while another installation or any scope is still open throws `InstallationConflictError`, whose message says what is still open.
|
|
235
272
|
Await `installation.close()` before installing a replacement.
|
|
236
273
|
- Providing the same dependency twice in one installation is rejected, exactly like in `withOverrides`.
|
|
237
|
-
-
|
|
274
|
+
- Install once during startup.
|
|
275
|
+
Late installation is supported for a controlled bootstrap or a test setup: it applies to the reads that come after it, and closing it brings the earlier values back.
|
|
276
|
+
- If a resource needs the lifetime of each installation, define its dependency without a built-in factory and export a module provision builder that returns `provideFactory`.
|
|
277
|
+
This keeps the factory body in its owning module, but removes the built-in fallback: reading the dependency without that provision throws `MissingProviderError`.
|
|
238
278
|
- Every worker thread and every process wires its own installation.
|
|
239
|
-
- In tests, install once for the whole process and
|
|
279
|
+
- In tests, install once for the whole process and override per test, as shown in [Test your application](#test-your-application).
|
|
240
280
|
|
|
241
281
|
### Collect the providers of several modules
|
|
242
282
|
|
|
@@ -264,17 +304,19 @@ export function getPlatformProvisions() {
|
|
|
264
304
|
|
|
265
305
|
```ts
|
|
266
306
|
// startup.ts
|
|
267
|
-
|
|
307
|
+
import { dispose, install } from "ripple-di"
|
|
308
|
+
|
|
309
|
+
install([
|
|
268
310
|
...getCoreProvisions(),
|
|
269
311
|
...getPlatformProvisions(),
|
|
270
312
|
])
|
|
271
313
|
|
|
272
|
-
onShutdown(() =>
|
|
314
|
+
onShutdown(() => dispose())
|
|
273
315
|
```
|
|
274
316
|
|
|
275
317
|
- Export a function that builds the provisions rather than a ready-made array, so each installation gets provisions of its own.
|
|
276
318
|
A provision that hands over ownership belongs to a single installation and cannot be reused by the next one.
|
|
277
|
-
- The composition root that installs the provisions
|
|
319
|
+
- The composition root that installs the provisions also calls `dispose()` from the shutdown hook the application already has.
|
|
278
320
|
- Tests have the same single composition root: one preload installs the provisions of every layer the suite needs.
|
|
279
321
|
Separate preloads installing on their own would fail on the second `install` instead of adding their providers.
|
|
280
322
|
|
|
@@ -282,6 +324,7 @@ onShutdown(() => installation.close())
|
|
|
282
324
|
|
|
283
325
|
Call `dispose()` when the application shuts down.
|
|
284
326
|
It closes every scope and cleans up every owned value still held by the module-level API, including an active installation.
|
|
327
|
+
Closing only the installation can leave [application-owned values](#where-a-value-belongs) cached.
|
|
285
328
|
|
|
286
329
|
```ts
|
|
287
330
|
import { dispose } from "ripple-di"
|
|
@@ -294,6 +337,66 @@ Values created inside `withOverrides` never wait for shutdown; they are cleaned
|
|
|
294
337
|
`dispose()` is final: afterwards the runtime cannot resolve dependencies, create scopes, or install providers, and those calls throw `ScopeClosedError`.
|
|
295
338
|
Do not use it to reset state between tests — use `withOverrides` for that, or create a separate runtime for each lifecycle.
|
|
296
339
|
|
|
340
|
+
## Test your application
|
|
341
|
+
|
|
342
|
+
Install the wiring the suite needs once for the whole test process, from the preload or setup file the test runner already has, and give each test the values of its own.
|
|
343
|
+
|
|
344
|
+
```ts
|
|
345
|
+
import {
|
|
346
|
+
createOverrideRunner,
|
|
347
|
+
createScope,
|
|
348
|
+
provide,
|
|
349
|
+
type Scope,
|
|
350
|
+
withOverrides,
|
|
351
|
+
} from "ripple-di"
|
|
352
|
+
|
|
353
|
+
test("loads users from the test database", () =>
|
|
354
|
+
withOverrides(
|
|
355
|
+
provide(useConfig, testConfig),
|
|
356
|
+
() => loadUsers(),
|
|
357
|
+
),
|
|
358
|
+
)
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
The scope lives exactly as long as that callback, so tests running in parallel cannot see one another's overrides.
|
|
362
|
+
|
|
363
|
+
When several tests share one set of values that is expensive to build, create the scope once and run each test inside it.
|
|
364
|
+
|
|
365
|
+
```ts
|
|
366
|
+
let scope: Scope
|
|
367
|
+
|
|
368
|
+
beforeAll(() => {
|
|
369
|
+
scope = createScope(provide(useConfig, testConfig))
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
afterAll(() => scope.close())
|
|
373
|
+
|
|
374
|
+
test("lists users", () => scope.run(() => listUsers()))
|
|
375
|
+
test("creates a user", () => scope.run(() => createUser()))
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
Entering a scope in a hook does not carry it into the tests that follow.
|
|
379
|
+
|
|
380
|
+
```ts
|
|
381
|
+
// Wrong: the scope is current inside this callback and nowhere else.
|
|
382
|
+
beforeAll(() => scope.run(() => {}))
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
- `scope.run` and `withOverrides` make a scope current for their own callback only, so every test that needs the scope runs inside one of them.
|
|
386
|
+
- Tests that share a suite scope also share the values cached in it, so keep that recipe for values they can safely reuse.
|
|
387
|
+
- `scope.run` does not report child scopes a test leaves open, so a test that creates one closes it itself instead of leaving it until `afterAll`.
|
|
388
|
+
|
|
389
|
+
When every test in a file needs the same overrides in a separate scope, a runner applies them to each test independently.
|
|
390
|
+
|
|
391
|
+
```ts
|
|
392
|
+
const testConfigOverrides = createOverrideRunner(() =>
|
|
393
|
+
provide(useConfig, testConfig),
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
test("lists users", testConfigOverrides.wrap(() => listUsers()))
|
|
397
|
+
test("creates a user", testConfigOverrides.wrap(() => createUser()))
|
|
398
|
+
```
|
|
399
|
+
|
|
297
400
|
## Advanced usage
|
|
298
401
|
|
|
299
402
|
Everything below is optional.
|
|
@@ -320,6 +423,7 @@ provide(useDb, fakeDb, { dispose: db => db.closeImmediately() })
|
|
|
320
423
|
|
|
321
424
|
A function passed to `provide` stays an ordinary function value; use `provideFactory` when it should build the value instead.
|
|
322
425
|
The dependencies an override factory reads are tracked like the ones read by the factory it replaces.
|
|
426
|
+
An override factory cannot read the previous value of the dependency it replaces; define a base dependency and a decorated one instead.
|
|
323
427
|
`dispose: true` reuses the disposer from `defineDependency`, so it throws right away when the dependency declares none.
|
|
324
428
|
|
|
325
429
|
A provision that hands over ownership belongs to a single scope or installation.
|
|
@@ -332,15 +436,13 @@ const useQueue = defineDependency<Queue>({
|
|
|
332
436
|
dispose: queue => queue.close(),
|
|
333
437
|
})
|
|
334
438
|
|
|
335
|
-
|
|
336
|
-
provideFactory(useQueue, () => createQueue(queueUrl)),
|
|
337
|
-
])
|
|
439
|
+
install(provideFactory(useQueue, () => createQueue(queueUrl)))
|
|
338
440
|
|
|
339
|
-
// Later, at shutdown:
|
|
340
|
-
await
|
|
441
|
+
// Later, at application shutdown:
|
|
442
|
+
await dispose()
|
|
341
443
|
```
|
|
342
444
|
|
|
343
|
-
The installed factory is lazy, and its queue client is closed with the installation.
|
|
445
|
+
The installed factory is lazy, and its queue client is closed either with the installation during a controlled replacement or with the runtime at application shutdown.
|
|
344
446
|
|
|
345
447
|
### Manage a scope explicitly
|
|
346
448
|
|
|
@@ -350,9 +452,7 @@ Use `createScope` when several operations share the same overrides and close at
|
|
|
350
452
|
```ts
|
|
351
453
|
import { createScope, provide } from "ripple-di"
|
|
352
454
|
|
|
353
|
-
const scope = createScope(
|
|
354
|
-
provide(useConfig, tenantConfig),
|
|
355
|
-
])
|
|
455
|
+
const scope = createScope(provide(useConfig, tenantConfig))
|
|
356
456
|
|
|
357
457
|
try {
|
|
358
458
|
await scope.run(() => processTenant())
|
|
@@ -361,13 +461,36 @@ try {
|
|
|
361
461
|
}
|
|
362
462
|
```
|
|
363
463
|
|
|
364
|
-
- `scope.run` makes the scope current for a callback without closing it.
|
|
464
|
+
- `scope.run` makes the scope current for a callback without closing it, and returns the callback's result unchanged.
|
|
365
465
|
- `scope.resolve(useDb)` reads a dependency from that scope rather than the current one.
|
|
466
|
+
A factory reads from its own scope only, so calling `scope.resolve` on a different scope inside one throws `CrossScopeResolutionError`.
|
|
366
467
|
- `scope.createScope` and `scope.withOverrides` create children of that scope instead of the current one.
|
|
367
468
|
- A child of the scope that `withOverrides` created must be closed before the callback returns, otherwise Ripple DI closes it and throws `LeakedChildScopeError`.
|
|
368
469
|
- `scope.close()` closes the scope and everything below it, while `scope.retire()` waits for child scopes to finish first.
|
|
369
470
|
- Closing disposes what the scope itself created; reused application values stay open.
|
|
370
471
|
- Cleanup continues past a failing disposer and reports every failure in one `AggregateError`.
|
|
472
|
+
- A disposer, and any async work it starts, cannot read dependencies or manage scopes and installations in the runtime being closed; misuse throws `DisposerContextError`.
|
|
473
|
+
Put everything cleanup needs into the dependency value itself.
|
|
474
|
+
|
|
475
|
+
### Name an override you write repeatedly
|
|
476
|
+
|
|
477
|
+
When a dependency is replaced in only one place, keep `withOverrides` with one `provide` at the call site.
|
|
478
|
+
For repeated replacements, `createValueOverride` turns the pattern into a named helper.
|
|
479
|
+
|
|
480
|
+
```ts
|
|
481
|
+
import { createValueOverride } from "ripple-di"
|
|
482
|
+
|
|
483
|
+
const withOpenAiClient = createValueOverride(useOpenAiClient)
|
|
484
|
+
|
|
485
|
+
await withOpenAiClient(client, () => createTextEmbedding(text))
|
|
486
|
+
```
|
|
487
|
+
|
|
488
|
+
Each call supplies the value it is given to one callback, exactly like writing `withOverrides` with a single `provide` by hand.
|
|
489
|
+
Pass the ownership options once, and every call cleans its own value up.
|
|
490
|
+
|
|
491
|
+
```ts
|
|
492
|
+
const withConnection = createValueOverride(useConnection, { dispose: true })
|
|
493
|
+
```
|
|
371
494
|
|
|
372
495
|
### Reuse one set of overrides
|
|
373
496
|
|
|
@@ -377,9 +500,9 @@ A long-lived object such as an API caller or a job worker is created once, but e
|
|
|
377
500
|
```ts
|
|
378
501
|
import { createOverrideRunner, provide } from "ripple-di"
|
|
379
502
|
|
|
380
|
-
const jobOverrides = createOverrideRunner(() =>
|
|
503
|
+
const jobOverrides = createOverrideRunner(() =>
|
|
381
504
|
provide(useJobContext, createJobContext(), { dispose: true }),
|
|
382
|
-
|
|
505
|
+
)
|
|
383
506
|
|
|
384
507
|
const worker = createWorker(job => jobOverrides.run(() => handleJob(job)))
|
|
385
508
|
```
|
|
@@ -403,9 +526,9 @@ That also suits a file of independent test cases that share one set of overrides
|
|
|
403
526
|
`extend` returns a runner with one more layer of overrides and leaves the runner it extends unchanged:
|
|
404
527
|
|
|
405
528
|
```ts
|
|
406
|
-
const tenantOverrides = jobOverrides.extend(() =>
|
|
529
|
+
const tenantOverrides = jobOverrides.extend(() =>
|
|
407
530
|
provide(useTenantResolver, tenantResolver),
|
|
408
|
-
|
|
531
|
+
)
|
|
409
532
|
|
|
410
533
|
await tenantOverrides.run(() => handleRequest())
|
|
411
534
|
```
|
|
@@ -449,6 +572,7 @@ await Promise.all([
|
|
|
449
572
|
```
|
|
450
573
|
|
|
451
574
|
A dependency belongs to the runtime that defined it and cannot be read or overridden in another one.
|
|
575
|
+
The module-level `defineDependency` always defines a dependency of the built-in runtime, so an application factory like the one above defines every dependency it needs through its own runtime.
|
|
452
576
|
Pass `name` to `createRuntime` to see that name in error messages.
|
|
453
577
|
Every runtime has the same methods, and each has a module-level counterpart that targets the built-in runtime:
|
|
454
578
|
|
|
@@ -471,36 +595,31 @@ Errors thrown by your own factory arrive wrapped in `FactoryError` with the orig
|
|
|
471
595
|
A failed factory is not cached, so the next read tries again.
|
|
472
596
|
|
|
473
597
|
These errors name the dependencies involved.
|
|
474
|
-
|
|
598
|
+
An explicit `name` is always used when you provide one:
|
|
475
599
|
|
|
476
600
|
```ts
|
|
477
601
|
const useConfig = defineDependency(loadConfig, { name: "config" })
|
|
478
602
|
```
|
|
479
603
|
|
|
480
604
|
The name only affects messages.
|
|
605
|
+
Without an explicit name, Ripple DI uses a non-empty factory name or shows a generated name with the `defineDependency` call location, such as `dependency#21 (packages/core/src/openai/config.ts:36)`.
|
|
606
|
+
The location follows the runtime's stack and source maps, is captured once when the dependency is defined, and adds no work during resolution.
|
|
481
607
|
|
|
482
|
-
##
|
|
608
|
+
## Do not mix package copies
|
|
609
|
+
|
|
610
|
+
Two copies of Ripple DI, separately installed or bundled, each run their own graph and cannot be combined.
|
|
611
|
+
Do not pass dependencies or provisions between them, and do not call one copy's dependency inside a factory owned by the other.
|
|
612
|
+
Such a call is not detected: the dependency may resolve against its own copy instead of failing at the boundary.
|
|
613
|
+
|
|
614
|
+
Declare `ripple-di` as a peer dependency in any package that exports dependencies of its own.
|
|
615
|
+
|
|
616
|
+
## Caveats
|
|
483
617
|
|
|
484
|
-
- Factories are synchronous; disposers may be asynchronous.
|
|
485
|
-
A factory returns the value itself, so a value that implements `then`, such as a query builder or another awaitable client, is stored as it is.
|
|
486
|
-
A factory is rejected only when it returns a native `Promise` object, because dependency reads made after an `await` are not tracked.
|
|
487
|
-
When the promise is the value, wrap the result in `asValue`: `defineDependency(() => asValue(loadToken()))` defines a `Promise<Token>` dependency created once and awaited by its readers.
|
|
488
|
-
- A `withOverrides` callback that returns an awaitable value has it awaited, exactly like any promise returned from a callback, so returning a query builder runs its query.
|
|
489
|
-
Use the value inside the callback, and use `createScope` when it has to outlive the callback.
|
|
490
|
-
Returning it wrapped in an object avoids the await but hands back a value whose scope is already closed, together with everything that scope owned.
|
|
491
|
-
- A factory can read dependencies, but cannot create, enter, close, or retire scopes and installations in its own runtime; misuse throws `FactoryScopeOperationError`.
|
|
492
|
-
- A disposer, and any async work it starts, cannot read dependencies or manage scopes and installations in the runtime being closed; misuse throws `DisposerContextError`.
|
|
493
|
-
Put everything cleanup needs into the dependency value itself.
|
|
494
|
-
- A factory reads from its own scope only; `scope.resolve` on a different scope throws `CrossScopeResolutionError`.
|
|
495
618
|
- Only the dependency calls made while a factory runs are tracked.
|
|
496
|
-
|
|
497
|
-
- An override factory cannot read the previous value of the dependency it replaces; define a base dependency and a decorated one instead.
|
|
498
|
-
- A factory-created value is scoped only by the dependencies its factory reads.
|
|
499
|
-
A value whose factory reads none stays shared even when it is first requested inside a scope.
|
|
619
|
+
Everything else stays invisible: `process.env`, `Date.now()`, and any dependency called after the factory has returned.
|
|
500
620
|
- Read dependencies where you use them.
|
|
501
621
|
A module-level `const db = useDb()` freezes one scope's value forever.
|
|
502
|
-
-
|
|
503
|
-
Do not pass dependencies or provisions between them, and do not call one copy's dependency inside a factory owned by the other.
|
|
622
|
+
- When a factory catches an error from reading a dependency, its value is not shared: every scope that asks for it builds a new one.
|
|
504
623
|
|
|
505
624
|
## License
|
|
506
625
|
|
package/dist/index.mjs
CHANGED
|
@@ -170,13 +170,45 @@ var DependencyNodeImpl = class {
|
|
|
170
170
|
dispose;
|
|
171
171
|
dependency;
|
|
172
172
|
constructor(definition) {
|
|
173
|
-
|
|
173
|
+
const generatedName = `dependency#${this.id}`;
|
|
174
|
+
this.name = definition.name ?? (definition.definitionSite ? `${generatedName} (${definition.definitionSite})` : generatedName);
|
|
174
175
|
this.runtime = definition.runtime;
|
|
175
176
|
this.defaultFactory = definition.defaultFactory;
|
|
176
177
|
this.dispose = definition.dispose;
|
|
177
178
|
this.dependency = (() => this.runtime.readCallable(this));
|
|
178
179
|
}
|
|
179
180
|
};
|
|
181
|
+
/** Captures and reduces a definition stack before it enters private metadata. */
|
|
182
|
+
function captureDefinitionSite(caller) {
|
|
183
|
+
const error = /* @__PURE__ */ new Error();
|
|
184
|
+
const captureStackTrace = typeof Error.captureStackTrace === "function" ? Error.captureStackTrace : void 0;
|
|
185
|
+
if (captureStackTrace) captureStackTrace(error, caller);
|
|
186
|
+
const frames = error.stack?.split("\n").slice(1) ?? [];
|
|
187
|
+
const skippedFrames = captureStackTrace ? frames : frames.slice(2);
|
|
188
|
+
for (const frame of skippedFrames) {
|
|
189
|
+
const site = definitionSiteFromFrame(frame);
|
|
190
|
+
if (site) return site;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
function definitionSiteFromFrame(frame) {
|
|
194
|
+
let location = frame.trim().replace(/^at\s+/, "");
|
|
195
|
+
const openingParenthesis = location.lastIndexOf("(");
|
|
196
|
+
if (openingParenthesis >= 0 && location.endsWith(")")) location = location.slice(openingParenthesis + 1, -1);
|
|
197
|
+
else {
|
|
198
|
+
const atSign = location.lastIndexOf("@");
|
|
199
|
+
if (atSign >= 0) location = location.slice(atSign + 1);
|
|
200
|
+
location = location.replace(/^async\s+/, "");
|
|
201
|
+
}
|
|
202
|
+
const match = /^(.*):(\d+):\d+$/.exec(location);
|
|
203
|
+
if (!match || !match[1]) return;
|
|
204
|
+
let file = match[1].replace(/^file:\/\//, "").replaceAll("\\", "/");
|
|
205
|
+
try {
|
|
206
|
+
file = decodeURI(file);
|
|
207
|
+
} catch {}
|
|
208
|
+
const workingDirectory = `${process.cwd().replaceAll("\\", "/")}/`;
|
|
209
|
+
if (file.startsWith(workingDirectory)) file = file.slice(workingDirectory.length);
|
|
210
|
+
return `${file}:${match[2]}`;
|
|
211
|
+
}
|
|
180
212
|
/** Creates a property-free callable whose metadata lives only in a `WeakMap`. */
|
|
181
213
|
function createDependency(definition) {
|
|
182
214
|
const node = new DependencyNodeImpl(definition);
|
|
@@ -748,11 +780,15 @@ var RuntimeImpl = class {
|
|
|
748
780
|
this.root = new ScopeImpl(this, void 0, []);
|
|
749
781
|
}
|
|
750
782
|
defineDependency(factoryOrOptions, maybeOptions) {
|
|
783
|
+
return this.defineDependencyAt(factoryOrOptions, maybeOptions, captureDefinitionSite(this.defineDependency));
|
|
784
|
+
}
|
|
785
|
+
defineDependencyAt(factoryOrOptions, maybeOptions, definitionSite) {
|
|
751
786
|
const isFactory = typeof factoryOrOptions === "function";
|
|
752
787
|
const factory = isFactory ? factoryOrOptions : void 0;
|
|
753
788
|
const options = (isFactory ? maybeOptions : factoryOrOptions) ?? {};
|
|
754
789
|
return createDependency({
|
|
755
|
-
name: options.name,
|
|
790
|
+
name: options.name ?? (factory?.name || void 0),
|
|
791
|
+
definitionSite,
|
|
756
792
|
runtime: this,
|
|
757
793
|
defaultFactory: factory,
|
|
758
794
|
dispose: options.dispose
|
|
@@ -891,8 +927,7 @@ function createRuntime(options = {}) {
|
|
|
891
927
|
}
|
|
892
928
|
const globalRuntime = new RuntimeImpl({ name: "global" });
|
|
893
929
|
function defineDependency(factoryOrOptions, maybeOptions) {
|
|
894
|
-
|
|
895
|
-
return globalRuntime.defineDependency(factoryOrOptions);
|
|
930
|
+
return globalRuntime.defineDependencyAt(factoryOrOptions, maybeOptions, captureDefinitionSite(defineDependency));
|
|
896
931
|
}
|
|
897
932
|
/**
|
|
898
933
|
* Installs long-lived providers for module-level dependencies.
|
package/package.json
CHANGED
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ripple-di",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "1.0.
|
|
5
|
-
"description": "Scoped dependency injection for TypeScript with automatic dependency tracking
|
|
4
|
+
"version": "1.0.1",
|
|
5
|
+
"description": "Scoped dependency injection for TypeScript with automatic dependency tracking, lifecycle-aware cleanup, and no container lookups in application code.",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"dependency-injection",
|
|
8
8
|
"di",
|
|
9
9
|
"inversion-of-control",
|
|
10
10
|
"ioc",
|
|
11
|
+
"service-locator",
|
|
11
12
|
"typescript",
|
|
12
13
|
"scoped",
|
|
14
|
+
"dependency-tracking",
|
|
15
|
+
"dependency-graph",
|
|
13
16
|
"async-context",
|
|
14
17
|
"asynclocalstorage",
|
|
15
18
|
"async-hooks",
|
|
@@ -22,7 +25,7 @@
|
|
|
22
25
|
"engines": {
|
|
23
26
|
"node": ">=18"
|
|
24
27
|
},
|
|
25
|
-
"packageManager": "bun@1.
|
|
28
|
+
"packageManager": "bun@1.4.0",
|
|
26
29
|
"repository": {
|
|
27
30
|
"type": "git",
|
|
28
31
|
"url": "git+https://github.com/IlyaSemenov/ripple-di.git"
|
|
@@ -56,7 +59,7 @@
|
|
|
56
59
|
"@biomejs/biome": "^2.5.5",
|
|
57
60
|
"@changesets/cli": "^2.31.1",
|
|
58
61
|
"@tsconfig/bun": "^1.0.10",
|
|
59
|
-
"@types/bun": "^1.
|
|
62
|
+
"@types/bun": "^1.4.0",
|
|
60
63
|
"publint": "^0.3.22",
|
|
61
64
|
"tsdown": "^0.22.14",
|
|
62
65
|
"typescript": "^7.0.2"
|