@fougere/container 0.3.0-alpha.0 → 0.5.0-alpha.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/README.md +1 -1
- package/package.json +3 -2
- package/src/container.ts +92 -0
- package/src/create.ts +148 -0
- package/src/index.ts +7 -0
package/README.md
CHANGED
|
@@ -17,4 +17,4 @@ pnpm add @fougere/container
|
|
|
17
17
|
|
|
18
18
|
Part of [Fougere](https://github.com/chok/fougere) — one schema, a gradient from
|
|
19
19
|
monolith to distributed, the same user code.
|
|
20
|
-
Reference documentation: [the site](https://
|
|
20
|
+
Reference documentation: [the site](https://fougere.dev/) (en/fr).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fougere/container",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0-alpha.0",
|
|
4
4
|
"description": "Fougere's DI container: resolution by type, wired from the AST scan. Zero dependencies.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"fougere",
|
|
@@ -26,7 +26,8 @@
|
|
|
26
26
|
}
|
|
27
27
|
},
|
|
28
28
|
"files": [
|
|
29
|
-
"dist"
|
|
29
|
+
"dist",
|
|
30
|
+
"src"
|
|
30
31
|
],
|
|
31
32
|
"devDependencies": {
|
|
32
33
|
"vitest": "^4.1.0"
|
package/src/container.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A class constructor with any arguments.
|
|
3
|
+
*/
|
|
4
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
5
|
+
export type Constructor<T = unknown> = new (...args: any[]) => T;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Registration options.
|
|
9
|
+
*/
|
|
10
|
+
export interface RegisterOptions {
|
|
11
|
+
/**
|
|
12
|
+
* Lifetime of the resolved value. Default: 'transient'.
|
|
13
|
+
*
|
|
14
|
+
* Two words, because two are used: `Config` is a singleton, every handler,
|
|
15
|
+
* presenter, collector and provider is transient — a fresh instance per
|
|
16
|
+
* resolution, which is what keeps one call's state out of the next. A third,
|
|
17
|
+
* `'scoped'`, was declared and never passed by any caller: {@link Container.createScope}
|
|
18
|
+
* already answers "one instance per frond, per surface", and two mechanisms for
|
|
19
|
+
* one need is one too many.
|
|
20
|
+
*/
|
|
21
|
+
lifetime?: 'singleton' | 'transient';
|
|
22
|
+
/** Dependency type names for type-based resolution (from AST scan). */
|
|
23
|
+
deps?: string[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Anything holding a resource can say so, and disposing the container says it back.
|
|
28
|
+
*
|
|
29
|
+
* Not an interface a class implements — a shape a class happens to have. A handler
|
|
30
|
+
* that opens nothing declares nothing.
|
|
31
|
+
*/
|
|
32
|
+
export interface Disposable {
|
|
33
|
+
dispose(): void | Promise<void>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* DI container interface — the only thing application code sees.
|
|
38
|
+
*
|
|
39
|
+
* It is deliberately small, and the reason is that the scan already knows the graph:
|
|
40
|
+
* every class and every dependency is read from source before boot, so there is
|
|
41
|
+
* almost nothing left to bind late. What remains is a Map with a parent chain, plus
|
|
42
|
+
* the two gestures the scan cannot cover — {@link createScope} for isolation, and
|
|
43
|
+
* {@link setFallback} for a frond that lives in another process.
|
|
44
|
+
*
|
|
45
|
+
* Resolution is by name, and the names are TYPE names produced by the AST scan.
|
|
46
|
+
* The container never sees a type; "DI by type" is realized upstream, in the
|
|
47
|
+
* scanner, which is why this file mentions neither.
|
|
48
|
+
*/
|
|
49
|
+
export interface Container {
|
|
50
|
+
/** Register a class by name. Its `deps` are resolved from this container. */
|
|
51
|
+
register<T>(name: string, ctor: Constructor<T>, options?: RegisterOptions): void;
|
|
52
|
+
|
|
53
|
+
/** Register a pre-built value by name. */
|
|
54
|
+
registerValue<T>(name: string, value: T): void;
|
|
55
|
+
|
|
56
|
+
/** Resolve a dependency by name. */
|
|
57
|
+
resolve<T>(name: string): T;
|
|
58
|
+
|
|
59
|
+
/** Check if a name is registered (including parent scopes). */
|
|
60
|
+
has(name: string): boolean;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* A resolver of last resort, consulted when no scope holds the name.
|
|
64
|
+
*
|
|
65
|
+
* It exists for one reason: a frond declared in `remotes` registers nothing locally,
|
|
66
|
+
* so its façade cannot be *found* — it has to be fabricated. Set on the root, inherited
|
|
67
|
+
* by every scope. Returning `undefined` means "I don't know either", and the miss
|
|
68
|
+
* throws as before.
|
|
69
|
+
*
|
|
70
|
+
* Optional: a container without one behaves exactly as it did.
|
|
71
|
+
*/
|
|
72
|
+
setFallback?(resolve: (name: string) => unknown): void;
|
|
73
|
+
|
|
74
|
+
/** Create a child scope. Inherits parent registrations. */
|
|
75
|
+
createScope(): Container;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Dispose the container: every instance it KEPT — the singletons — that has a
|
|
79
|
+
* `dispose` method is told, most recent first, and awaited. A failure does not
|
|
80
|
+
* silence the rest; they travel out together in an `AggregateError`.
|
|
81
|
+
*
|
|
82
|
+
* This used to clear the registry and nothing else, while the doc above it promised
|
|
83
|
+
* disposal — so `await using app` (which routes here through `app[Symbol.asyncDispose]`)
|
|
84
|
+
* announced a cleanup that never happened. A container that holds instances is the
|
|
85
|
+
* only thing that knows they exist; if it stays silent, nobody else can speak.
|
|
86
|
+
*
|
|
87
|
+
* What it does NOT dispose: a transient, handed over and forgotten in the same
|
|
88
|
+
* breath, whose caller is the one who knows when it is done; and a value passed to
|
|
89
|
+
* {@link registerValue}, which the container did not build.
|
|
90
|
+
*/
|
|
91
|
+
dispose(): Promise<void>;
|
|
92
|
+
}
|
package/src/create.ts
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import type { Container, RegisterOptions, Constructor, Disposable } from './container.js';
|
|
2
|
+
|
|
3
|
+
interface Entry {
|
|
4
|
+
factory: (container: Container) => unknown;
|
|
5
|
+
lifetime: 'singleton' | 'transient';
|
|
6
|
+
instance?: unknown;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface ScopeContainer extends Container {
|
|
10
|
+
_getEntry(name: string): Entry | undefined;
|
|
11
|
+
_getFallback(): ((name: string) => unknown) | undefined;
|
|
12
|
+
_forget(child: ScopeContainer): void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const isDisposable = (value: unknown): value is Disposable =>
|
|
16
|
+
typeof value === 'object' && value !== null &&
|
|
17
|
+
typeof (value as Disposable).dispose === 'function';
|
|
18
|
+
|
|
19
|
+
function createScope(parent?: ScopeContainer): ScopeContainer {
|
|
20
|
+
const registry = new Map<string, Entry>();
|
|
21
|
+
// Construction order, so disposal can run in reverse: a thing built later may
|
|
22
|
+
// hold something built earlier.
|
|
23
|
+
const built: unknown[] = [];
|
|
24
|
+
// The scopes opened from this one. A child is built BY this container, so it is this
|
|
25
|
+
// container's to close — and it is closed first, because it may hold what the parent
|
|
26
|
+
// built while the parent holds nothing of its. Without this a frond's scope, which is
|
|
27
|
+
// where every provider lives, was never disposed at all: it is registered as a VALUE
|
|
28
|
+
// under `frond:<name>`, and a value is not the container's to dispose.
|
|
29
|
+
const children: ScopeContainer[] = [];
|
|
30
|
+
let fallback: ((name: string) => unknown) | undefined;
|
|
31
|
+
|
|
32
|
+
const remember = <T>(value: T): T => {
|
|
33
|
+
if (isDisposable(value)) built.push(value);
|
|
34
|
+
return value;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const container: ScopeContainer = {
|
|
38
|
+
register<T>(name: string, ctor: Constructor<T>, options?: RegisterOptions) {
|
|
39
|
+
const lifetime = options?.lifetime ?? 'transient';
|
|
40
|
+
const deps = options?.deps ?? [];
|
|
41
|
+
registry.set(name, {
|
|
42
|
+
factory: (c) => new ctor(...deps.map((d) => c.resolve(d))),
|
|
43
|
+
lifetime,
|
|
44
|
+
});
|
|
45
|
+
},
|
|
46
|
+
|
|
47
|
+
registerValue<T>(name: string, value: T) {
|
|
48
|
+
// A value the container did not build is not the container's to dispose.
|
|
49
|
+
registry.set(name, { factory: () => value, lifetime: 'singleton', instance: value });
|
|
50
|
+
},
|
|
51
|
+
|
|
52
|
+
resolve<T>(name: string): T {
|
|
53
|
+
let entry = registry.get(name);
|
|
54
|
+
|
|
55
|
+
// Not found locally — the parent holds it, and holds its instance too.
|
|
56
|
+
if (!entry && parent && parent._getEntry(name)) {
|
|
57
|
+
return parent.resolve<T>(name);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Nobody holds it. Before failing, ask whoever set a last resort — a frond declared
|
|
61
|
+
// in `remotes` registers nothing here, so its façade is fabricated rather than found.
|
|
62
|
+
if (!entry) {
|
|
63
|
+
const made = container._getFallback()?.(name);
|
|
64
|
+
if (made !== undefined) {
|
|
65
|
+
registry.set(name, { factory: () => made, lifetime: 'singleton', instance: made });
|
|
66
|
+
return made as T;
|
|
67
|
+
}
|
|
68
|
+
throw new Error(`[container] '${name}' is not registered`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (entry.instance !== undefined) return entry.instance as T;
|
|
72
|
+
const value = entry.factory(container) as T;
|
|
73
|
+
// The container disposes what it KEEPS. A transient is handed over and
|
|
74
|
+
// forgotten in the same breath — remembering it would be a leak that grows
|
|
75
|
+
// once per call, and its caller is the one who knows when it is done.
|
|
76
|
+
if (entry.lifetime === 'singleton') {
|
|
77
|
+
entry.instance = value;
|
|
78
|
+
remember(value);
|
|
79
|
+
}
|
|
80
|
+
return value;
|
|
81
|
+
},
|
|
82
|
+
|
|
83
|
+
has(name: string): boolean {
|
|
84
|
+
return registry.has(name) || (parent?.has(name) ?? false);
|
|
85
|
+
},
|
|
86
|
+
|
|
87
|
+
createScope(): Container {
|
|
88
|
+
const child = createScope(container);
|
|
89
|
+
children.push(child);
|
|
90
|
+
return child;
|
|
91
|
+
},
|
|
92
|
+
|
|
93
|
+
async dispose(): Promise<void> {
|
|
94
|
+
// Reverse order, and one failure must not silence the rest: everything gets
|
|
95
|
+
// told, then the errors travel together.
|
|
96
|
+
// Its parent kept a reference so it could close this scope; the scope closing itself
|
|
97
|
+
// makes that reference garbage. Nothing created a scope at RUN time until frames did,
|
|
98
|
+
// so the list only ever grew at boot and stayed bounded — one per request, or one per
|
|
99
|
+
// transaction, and it grows for the life of the process.
|
|
100
|
+
parent?._forget(container);
|
|
101
|
+
const failures: unknown[] = [];
|
|
102
|
+
for (const child of children.reverse()) {
|
|
103
|
+
try {
|
|
104
|
+
await child.dispose();
|
|
105
|
+
} catch (error) {
|
|
106
|
+
failures.push(error);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
children.length = 0;
|
|
110
|
+
for (const value of built.reverse()) {
|
|
111
|
+
try {
|
|
112
|
+
await (value as Disposable).dispose();
|
|
113
|
+
} catch (error) {
|
|
114
|
+
failures.push(error);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
built.length = 0;
|
|
118
|
+
registry.clear();
|
|
119
|
+
if (failures.length > 0) {
|
|
120
|
+
throw new AggregateError(failures, '[container] one or more disposals failed');
|
|
121
|
+
}
|
|
122
|
+
},
|
|
123
|
+
|
|
124
|
+
setFallback(resolve: (name: string) => unknown) {
|
|
125
|
+
fallback = resolve;
|
|
126
|
+
},
|
|
127
|
+
|
|
128
|
+
_getEntry(name: string): Entry | undefined {
|
|
129
|
+
return registry.get(name) ?? parent?._getEntry(name);
|
|
130
|
+
},
|
|
131
|
+
|
|
132
|
+
_forget(child: ScopeContainer) {
|
|
133
|
+
const at = children.indexOf(child);
|
|
134
|
+
if (at !== -1) children.splice(at, 1);
|
|
135
|
+
},
|
|
136
|
+
|
|
137
|
+
/** Set on the root, honoured from any scope — a scope inherits it by asking upward. */
|
|
138
|
+
_getFallback() {
|
|
139
|
+
return fallback ?? parent?._getFallback();
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
return container;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function createContainer(): Container {
|
|
147
|
+
return createScope();
|
|
148
|
+
}
|