alloy-di 1.3.0 → 1.5.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.
Files changed (35) hide show
  1. package/README.md +1 -1
  2. package/dist/lib/container.d.ts +98 -7
  3. package/dist/lib/container.js +210 -27
  4. package/dist/lib/decorators.d.ts +1 -1
  5. package/dist/lib/providers.d.ts +27 -3
  6. package/dist/lib/providers.js +23 -1
  7. package/dist/lib/scope.d.ts +38 -2
  8. package/dist/plugins/core/codegen.js +40 -4
  9. package/dist/plugins/core/container-loader.js +21 -7
  10. package/dist/plugins/core/decorators.js +12 -22
  11. package/dist/plugins/core/discovery-runtime.js +19 -4
  12. package/dist/plugins/core/discovery-store.js +20 -6
  13. package/dist/plugins/core/scanner.js +71 -7
  14. package/dist/plugins/core/scopes-validation.d.ts +19 -0
  15. package/dist/plugins/core/scopes-validation.js +225 -0
  16. package/dist/plugins/core/types.d.ts +9 -4
  17. package/dist/plugins/core/visualizer.d.ts +2 -2
  18. package/dist/plugins/core/visualizer.js +106 -12
  19. package/dist/plugins/rollup-plugin/barrel-exports.js +51 -0
  20. package/dist/plugins/rollup-plugin/dependency-resolution.js +42 -0
  21. package/dist/plugins/rollup-plugin/index.d.ts +2 -23
  22. package/dist/plugins/rollup-plugin/index.js +15 -128
  23. package/dist/plugins/rollup-plugin/manifest-deps.js +48 -0
  24. package/dist/plugins/rollup-plugin/package-exports.js +20 -0
  25. package/dist/plugins/rollup-plugin/types.d.ts +36 -0
  26. package/dist/plugins/vite-plugin/index.d.ts +9 -1
  27. package/dist/plugins/vite-plugin/index.js +14 -5
  28. package/dist/runtime.d.ts +4 -3
  29. package/dist/runtime.js +3 -2
  30. package/dist/scopes.d.ts +73 -0
  31. package/dist/scopes.js +167 -0
  32. package/dist/test.d.ts +37 -3
  33. package/dist/test.js +20 -2
  34. package/dist/tsconfig.tsbuildinfo +1 -1
  35. package/package.json +7 -2
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # alloy-di
2
2
 
3
- `alloy-di` is a compile-time dependency injection toolkit for Vite. It scans your TypeScript during build, generates a static container, and ships a tiny runtime so you get dependency injection without reflection overhead.
3
+ `alloy-di` is a build-time dependency injection toolkit for Vite. It scans your TypeScript during build, generates a static container, and ships a tiny runtime so you get dependency injection without reflection overhead.
4
4
 
5
5
  ## Highlights
6
6
 
@@ -1,20 +1,52 @@
1
1
  import { Constructor, Newable, Token } from "./types.js";
2
+ import { FactoryCacheEntry, FactoryPendingEntry, ResolutionContext, ServiceScope } from "./scope.js";
2
3
  import { ServiceIdentifier } from "./service-identifiers.js";
3
4
 
4
5
  //#region src/lib/container.d.ts
6
+ /**
7
+ * The container-like surface a factory receives. Both the root `Container` and
8
+ * a `Scope` satisfy it, so a factory can resolve its own dependencies against
9
+ * whichever context owns its lifecycle (root for `singleton`, the scope for a
10
+ * custom-scoped factory).
11
+ */
12
+ interface FactoryContext {
13
+ get<T>(target: Newable<T> | ServiceIdentifier<T>): Promise<T>;
14
+ getToken<T>(token: Token<T>): T;
15
+ }
16
+ /** A token-bound factory function executed at resolution time. */
17
+ type FactoryFn<T = unknown> = (context: FactoryContext) => T | Promise<T>;
5
18
  /**
6
19
  * Runtime dependency injection container used by generated modules and tests.
7
20
  *
8
21
  * It stores metadata discovered at build time, resolves constructor dependencies,
9
22
  * performs singleton caching, and supports token-based value providers.
10
23
  */
11
- declare class Container {
24
+ declare class Container implements ResolutionContext {
12
25
  private readonly singletons;
13
26
  private readonly pendingSingletons;
14
27
  private readonly instanceOverrides;
15
28
  private readonly metadataCache;
16
29
  private readonly valueProviders;
30
+ private readonly factoryRegistry;
31
+ private readonly factoryValues;
32
+ private readonly factoryPending;
33
+ private factoryGeneration;
17
34
  private readonly factoryWarningCache;
35
+ private readonly scopeHierarchy;
36
+ readonly scopeName: ServiceScope;
37
+ readonly parent: ResolutionContext | null;
38
+ getCached(target: Constructor): unknown;
39
+ setCached(target: Constructor, instance: unknown): void;
40
+ getPending(target: Constructor): Promise<unknown> | undefined;
41
+ setPending(target: Constructor, promise: Promise<unknown>): void;
42
+ deletePending(target: Constructor): void;
43
+ getProvider(tokenId: symbol): unknown;
44
+ hasProvider(tokenId: symbol): boolean;
45
+ getFactoryValue(tokenId: symbol): FactoryCacheEntry | undefined;
46
+ setFactoryValue(tokenId: symbol, generation: number, value: unknown): void;
47
+ getFactoryPending(tokenId: symbol): FactoryPendingEntry | undefined;
48
+ setFactoryPending(tokenId: symbol, generation: number, promise: Promise<unknown>): void;
49
+ deleteFactoryPending(tokenId: symbol, generation: number): void;
18
50
  /**
19
51
  * Resolve (and construct) the requested service.
20
52
  *
@@ -23,6 +55,14 @@ declare class Container {
23
55
  */
24
56
  get<T>(target: Newable<T>): Promise<T>;
25
57
  get<T>(identifier: ServiceIdentifier<T>): Promise<T>;
58
+ /** @internal */
59
+ _registerScopeHierarchy(hierarchy: Record<string, string>): void;
60
+ /** @internal */
61
+ _validateScopeParent(childScope: ServiceScope, parentScope: ServiceScope): void;
62
+ /** @internal */
63
+ _resolveInContext<T>(target: Newable<T> | ServiceIdentifier<T>, context: ResolutionContext, options?: {
64
+ skipFactoryWarning?: boolean;
65
+ }): Promise<T>;
26
66
  /**
27
67
  * Provide a concrete instance override for a class constructor.
28
68
  * Used by test utilities to inject mocks/stubs without altering global metadata.
@@ -45,6 +85,23 @@ declare class Container {
45
85
  * @param value - The value that should be injected when the token is requested.
46
86
  */
47
87
  provideValue<T>(token: Token<T>, value: T): void;
88
+ /**
89
+ * Register a factory function for an injection token. The factory runs at
90
+ * resolution time and receives the container so it can resolve its own
91
+ * dependencies.
92
+ *
93
+ * This is the imperative escape hatch backing the declarative `asFactory`
94
+ * provider; prefer `asFactory` + `applyProviders` for static registration.
95
+ *
96
+ * @param token - The token created via `createToken`.
97
+ * @param fn - Factory invoked with the resolving context; may be async.
98
+ * @param options.lifecycle - `singleton` (default, cached on the root),
99
+ * `transient` (re-run on every resolution), or a custom scope name (cached
100
+ * once per matching scope instance and disposed with it).
101
+ */
102
+ provideFactory<T>(token: Token<T>, fn: FactoryFn<T>, options?: {
103
+ lifecycle?: ServiceScope;
104
+ }): void;
48
105
  /**
49
106
  * Retrieve a provided value for a token from this container.
50
107
  * Throws if no provider is registered for the token.
@@ -62,10 +119,8 @@ declare class Container {
62
119
  * @throws Error if a circular dependency is detected
63
120
  */
64
121
  private resolve;
65
- /**
66
- * Resolve a singleton service with caching and in-flight creation coalescing.
67
- */
68
- private resolveSingleton;
122
+ private findContextForScope;
123
+ private resolveCached;
69
124
  /**
70
125
  * Instantiate a class by resolving and injecting all declared dependencies.
71
126
  * Handles factory-lazy services by importing the real class before instantiation.
@@ -108,9 +163,45 @@ declare class Container {
108
163
  */
109
164
  private runImporterWithRetry;
110
165
  /**
111
- * Resolve a token dependency via registered value providers.
166
+ * Resolve a token dependency via registered value providers or factories.
112
167
  */
113
168
  private resolveTokenLike;
169
+ /**
170
+ * Execute a token-bound factory according to its lifecycle, caching its
171
+ * result on the context that owns that lifecycle:
172
+ *
173
+ * - `transient`: run `fn` on every call against the current context; nothing
174
+ * is cached.
175
+ * - `singleton`: cache on the root container.
176
+ * - custom scope: cache on the nearest ancestor context whose `scopeName`
177
+ * matches; if none is active, fall back to transient behaviour (mirrors how
178
+ * class resolution treats a custom scope with no matching context).
179
+ */
180
+ private resolveFactory;
181
+ /**
182
+ * Resolve a factory whose result is cached on `targetCtx`, keyed by token id.
183
+ * Mirrors `resolveCached` for classes: return the cached value, else coalesce
184
+ * concurrent first-resolutions onto a single `pending` promise, else run the
185
+ * factory and cache. `pending` is cleared in `finally` so a rejected factory
186
+ * can be retried rather than caching the failure.
187
+ */
188
+ private resolveFactoryCached;
189
+ /**
190
+ * Invoke a factory's function against the context that owns its lifecycle,
191
+ * with a best-effort synchronous re-entrancy guard.
192
+ *
193
+ * `executing` is set only for the synchronous duration of `fn(...)` — long
194
+ * enough to catch a factory that resolves its own token before returning (a
195
+ * synchronous self-cycle, or a synchronous mutual cycle between factories),
196
+ * but cleared the moment `fn` returns so that concurrent resolutions which
197
+ * legitimately coalesce on an in-flight result are never mistaken for a cycle.
198
+ *
199
+ * Known limitation: a factory that re-enters its own token *after* awaiting
200
+ * (an async cross-await self-reference) is indistinguishable from valid
201
+ * coalescing without async-context tracking, so that case is not caught here
202
+ * — it is documented in the factory-providers guide.
203
+ */
204
+ private runFactory;
114
205
  /**
115
206
  * Format a readable representation of the resolution stack for error messages.
116
207
  */
@@ -121,4 +212,4 @@ declare class Container {
121
212
  private getServiceMetadata;
122
213
  }
123
214
  //#endregion
124
- export { Container };
215
+ export { Container, FactoryFn };
@@ -42,10 +42,77 @@ var Container = class {
42
42
  instanceOverrides = /* @__PURE__ */ new Map();
43
43
  metadataCache = /* @__PURE__ */ new Map();
44
44
  valueProviders = /* @__PURE__ */ new Map();
45
+ factoryRegistry = /* @__PURE__ */ new Map();
46
+ factoryValues = /* @__PURE__ */ new Map();
47
+ factoryPending = /* @__PURE__ */ new Map();
48
+ factoryGeneration = 0;
45
49
  factoryWarningCache = /* @__PURE__ */ new WeakSet();
50
+ scopeHierarchy = /* @__PURE__ */ new Map();
51
+ scopeName = ServiceScope.SINGLETON;
52
+ parent = null;
53
+ getCached(target) {
54
+ return this.singletons.get(target);
55
+ }
56
+ setCached(target, instance) {
57
+ this.singletons.set(target, instance);
58
+ }
59
+ getPending(target) {
60
+ return this.pendingSingletons.get(target);
61
+ }
62
+ setPending(target, promise) {
63
+ this.pendingSingletons.set(target, promise);
64
+ }
65
+ deletePending(target) {
66
+ this.pendingSingletons.delete(target);
67
+ }
68
+ getProvider(tokenId) {
69
+ return this.valueProviders.get(tokenId);
70
+ }
71
+ hasProvider(tokenId) {
72
+ return this.valueProviders.has(tokenId);
73
+ }
74
+ getFactoryValue(tokenId) {
75
+ return this.factoryValues.get(tokenId);
76
+ }
77
+ setFactoryValue(tokenId, generation, value) {
78
+ this.factoryValues.set(tokenId, {
79
+ generation,
80
+ value
81
+ });
82
+ }
83
+ getFactoryPending(tokenId) {
84
+ return this.factoryPending.get(tokenId);
85
+ }
86
+ setFactoryPending(tokenId, generation, promise) {
87
+ this.factoryPending.set(tokenId, {
88
+ generation,
89
+ promise
90
+ });
91
+ }
92
+ deleteFactoryPending(tokenId, generation) {
93
+ if (this.factoryPending.get(tokenId)?.generation === generation) this.factoryPending.delete(tokenId);
94
+ }
46
95
  async get(targetOrIdentifier) {
47
96
  if (typeof targetOrIdentifier === "symbol") return this.getByIdentifier(targetOrIdentifier);
48
- return this.getByConstructor(targetOrIdentifier);
97
+ return this.getByConstructor(targetOrIdentifier, this);
98
+ }
99
+ /** @internal */
100
+ _registerScopeHierarchy(hierarchy) {
101
+ for (const [child, parent] of Object.entries(hierarchy)) this.scopeHierarchy.set(child, parent);
102
+ }
103
+ /** @internal */
104
+ _validateScopeParent(childScope, parentScope) {
105
+ const declaredParent = this.scopeHierarchy.get(childScope);
106
+ if (declaredParent && declaredParent !== parentScope) throw new Error(`[alloy] Invalid scope hierarchy construction: scope '${String(childScope)}' is declared with parent '${String(declaredParent)}', but was constructed with parent scope '${String(parentScope)}'.`);
107
+ }
108
+ /** @internal */
109
+ async _resolveInContext(target, context, options) {
110
+ if (typeof target === "symbol") {
111
+ const ctor = getConstructorByIdentifier(target);
112
+ if (!ctor) throw new Error(`No service registered for identifier ${target.description ?? target.toString()}`);
113
+ return this.getByConstructor(ctor, context, { skipFactoryWarning: true });
114
+ }
115
+ return this.getByConstructor(target, context, options);
49
116
  }
50
117
  /**
51
118
  * Provide a concrete instance override for a class constructor.
@@ -69,7 +136,7 @@ var Container = class {
69
136
  async getByIdentifier(identifier) {
70
137
  const ctor = getConstructorByIdentifier(identifier);
71
138
  if (!ctor) throw new Error(`No service registered for identifier ${identifier.description ?? identifier.toString()}`);
72
- return this.getByConstructor(ctor, { skipFactoryWarning: true });
139
+ return this.getByConstructor(ctor, this, { skipFactoryWarning: true });
73
140
  }
74
141
  /**
75
142
  * Register a concrete value for an injection token at runtime.
@@ -81,16 +148,43 @@ var Container = class {
81
148
  this.valueProviders.set(token.id, value);
82
149
  }
83
150
  /**
151
+ * Register a factory function for an injection token. The factory runs at
152
+ * resolution time and receives the container so it can resolve its own
153
+ * dependencies.
154
+ *
155
+ * This is the imperative escape hatch backing the declarative `asFactory`
156
+ * provider; prefer `asFactory` + `applyProviders` for static registration.
157
+ *
158
+ * @param token - The token created via `createToken`.
159
+ * @param fn - Factory invoked with the resolving context; may be async.
160
+ * @param options.lifecycle - `singleton` (default, cached on the root),
161
+ * `transient` (re-run on every resolution), or a custom scope name (cached
162
+ * once per matching scope instance and disposed with it).
163
+ */
164
+ provideFactory(token, fn, options) {
165
+ this.factoryRegistry.set(token.id, {
166
+ fn,
167
+ lifecycle: options?.lifecycle ?? ServiceScope.SINGLETON,
168
+ description: token.description,
169
+ generation: ++this.factoryGeneration,
170
+ executing: false
171
+ });
172
+ }
173
+ /**
84
174
  * Retrieve a provided value for a token from this container.
85
175
  * Throws if no provider is registered for the token.
86
176
  */
87
177
  getToken(token) {
88
- if (!this.valueProviders.has(token.id)) throw new Error(`No provider registered for token ${token.description ?? String(token.id)}`);
178
+ if (!this.valueProviders.has(token.id)) {
179
+ const label = token.description ?? String(token.id);
180
+ if (this.factoryRegistry.has(token.id)) throw new Error(`Token ${label} is registered as a factory provider, which cannot be retrieved synchronously via getToken(). Declare it as a dependency so it resolves through the async resolution path instead.`);
181
+ throw new Error(`No provider registered for token ${label}`);
182
+ }
89
183
  return this.valueProviders.get(token.id);
90
184
  }
91
- async getByConstructor(target, options) {
185
+ async getByConstructor(target, context, options) {
92
186
  if (!options?.skipFactoryWarning) this.maybeWarnFactoryLazyConstructorUsage(target);
93
- return this.resolve(target, []);
187
+ return this.resolve(target, [], context);
94
188
  }
95
189
  maybeWarnFactoryLazyConstructorUsage(target) {
96
190
  if (!isDevEnvironment()) return;
@@ -107,7 +201,7 @@ var Container = class {
107
201
  * @returns Promise resolving to the service instance
108
202
  * @throws Error if a circular dependency is detected
109
203
  */
110
- async resolve(target, resolutionStack) {
204
+ async resolve(target, resolutionStack, context) {
111
205
  const overridden = this.instanceOverrides.get(target);
112
206
  if (overridden) return overridden;
113
207
  if (resolutionStack.includes(target)) throw new DependencyResolutionError(`Circular dependency detected: ${[...resolutionStack.map((t) => t.name), target.name].join(" -> ")}`, {
@@ -117,26 +211,40 @@ var Container = class {
117
211
  });
118
212
  const metadata = this.getServiceMetadata(target);
119
213
  const nextStack = [...resolutionStack, target];
120
- if (metadata.scope === ServiceScope.SINGLETON) return this.resolveSingleton(target, metadata, nextStack);
121
- return this.createInstance(target, metadata.dependencies, nextStack, metadata.factory);
214
+ const targetCtx = this.findContextForScope(metadata.scope, context);
215
+ if (metadata.scope === ServiceScope.SINGLETON) return this.resolveCached(target, metadata, nextStack, targetCtx);
216
+ if (metadata.scope === ServiceScope.TRANSIENT) return this.createInstance(target, metadata.dependencies, nextStack, metadata.factory, targetCtx);
217
+ if (targetCtx.scopeName === metadata.scope) return this.resolveCached(target, metadata, nextStack, targetCtx);
218
+ return this.createInstance(target, metadata.dependencies, nextStack, metadata.factory, targetCtx);
122
219
  }
123
- /**
124
- * Resolve a singleton service with caching and in-flight creation coalescing.
125
- */
126
- async resolveSingleton(target, metadata, resolutionStack) {
127
- const cached = this.singletons.get(target);
220
+ findContextForScope(scope, startingContext) {
221
+ if (scope === ServiceScope.SINGLETON) {
222
+ let current = startingContext;
223
+ while (current.parent) current = current.parent;
224
+ return current;
225
+ }
226
+ if (scope === ServiceScope.TRANSIENT) return startingContext;
227
+ let current = startingContext;
228
+ while (current) {
229
+ if (current.scopeName === scope) return current;
230
+ current = current.parent;
231
+ }
232
+ return startingContext;
233
+ }
234
+ async resolveCached(target, metadata, resolutionStack, targetCtx) {
235
+ const cached = targetCtx.getCached(target);
128
236
  if (cached) return cached;
129
- const pending = this.pendingSingletons.get(target);
237
+ const pending = targetCtx.getPending(target);
130
238
  if (pending) return await pending;
131
- const creation = this.createInstance(target, metadata.dependencies, resolutionStack, metadata.factory).then((instance) => {
132
- this.singletons.set(target, instance);
239
+ const creation = this.createInstance(target, metadata.dependencies, resolutionStack, metadata.factory, targetCtx).then((instance) => {
240
+ targetCtx.setCached(target, instance);
133
241
  return instance;
134
242
  });
135
- this.pendingSingletons.set(target, creation);
243
+ targetCtx.setPending(target, creation);
136
244
  try {
137
245
  return await creation;
138
246
  } finally {
139
- this.pendingSingletons.delete(target);
247
+ targetCtx.deletePending(target);
140
248
  }
141
249
  }
142
250
  /**
@@ -149,9 +257,9 @@ var Container = class {
149
257
  * @param factory - Optional lazy factory to import the real class
150
258
  * @returns Promise resolving to the instantiated service
151
259
  */
152
- async createInstance(target, dependencies, resolutionStack, factory) {
260
+ async createInstance(target, dependencies, resolutionStack, factory, context) {
153
261
  const ctor = factory ? await this.importWithRetry(factory, target, resolutionStack) : target;
154
- return new ctor(...await Promise.all(dependencies.map((param) => this.resolveParam(param, ctor, resolutionStack))));
262
+ return new ctor(...await Promise.all(dependencies.map((param) => this.resolveParam(param, ctor, resolutionStack, context))));
155
263
  }
156
264
  /**
157
265
  * Resolve a single dependency entry, handling lazies, tokens, and constructors.
@@ -163,7 +271,7 @@ var Container = class {
163
271
  * @returns Promise resolving to the dependency instance
164
272
  * @throws Error if dependency type is invalid
165
273
  */
166
- async resolveParam(param, target, resolutionStack) {
274
+ async resolveParam(param, target, resolutionStack, context) {
167
275
  const classification = classifyDependency(param);
168
276
  if (!classification) {
169
277
  const stackPath = this.formatStackPath(target, resolutionStack);
@@ -176,10 +284,10 @@ var Container = class {
176
284
  switch (classification.kind) {
177
285
  case "lazy": {
178
286
  const depClass = await this.importWithRetry(classification.lazy, target, resolutionStack);
179
- return this.resolve(depClass, resolutionStack);
287
+ return this.resolve(depClass, resolutionStack, context);
180
288
  }
181
- case "token": return this.resolveTokenLike(classification.token, target, resolutionStack);
182
- case "constructor": return this.resolve(classification.ctor, resolutionStack);
289
+ case "token": return this.resolveTokenLike(classification.token, target, resolutionStack, context);
290
+ case "constructor": return this.resolve(classification.ctor, resolutionStack, context);
183
291
  }
184
292
  return classification;
185
293
  }
@@ -236,10 +344,16 @@ var Container = class {
236
344
  }
237
345
  }
238
346
  /**
239
- * Resolve a token dependency via registered value providers.
347
+ * Resolve a token dependency via registered value providers or factories.
240
348
  */
241
- resolveTokenLike(tok, target, resolutionStack) {
242
- if (this.valueProviders.has(tok.id)) return this.valueProviders.get(tok.id);
349
+ resolveTokenLike(tok, target, resolutionStack, context) {
350
+ let current = context;
351
+ while (current) {
352
+ if (current.hasProvider(tok.id)) return current.getProvider(tok.id);
353
+ current = current.parent;
354
+ }
355
+ const factory = this.factoryRegistry.get(tok.id);
356
+ if (factory) return this.resolveFactory(tok.id, factory, context);
243
357
  const stackPath = this.formatStackPath(target, resolutionStack);
244
358
  throw new DependencyResolutionError(`No provider registered for token ${tok.description ?? String(tok.id)} while resolving ${target.name}. Resolution stack: ${stackPath}`, {
245
359
  target,
@@ -248,6 +362,75 @@ var Container = class {
248
362
  });
249
363
  }
250
364
  /**
365
+ * Execute a token-bound factory according to its lifecycle, caching its
366
+ * result on the context that owns that lifecycle:
367
+ *
368
+ * - `transient`: run `fn` on every call against the current context; nothing
369
+ * is cached.
370
+ * - `singleton`: cache on the root container.
371
+ * - custom scope: cache on the nearest ancestor context whose `scopeName`
372
+ * matches; if none is active, fall back to transient behaviour (mirrors how
373
+ * class resolution treats a custom scope with no matching context).
374
+ */
375
+ resolveFactory(tokenId, descriptor, context) {
376
+ if (descriptor.lifecycle === ServiceScope.TRANSIENT) return this.runFactory(descriptor, context);
377
+ const targetCtx = this.findContextForScope(descriptor.lifecycle, context);
378
+ if (descriptor.lifecycle !== ServiceScope.SINGLETON && targetCtx.scopeName !== descriptor.lifecycle) return this.runFactory(descriptor, context);
379
+ return this.resolveFactoryCached(tokenId, descriptor, targetCtx);
380
+ }
381
+ /**
382
+ * Resolve a factory whose result is cached on `targetCtx`, keyed by token id.
383
+ * Mirrors `resolveCached` for classes: return the cached value, else coalesce
384
+ * concurrent first-resolutions onto a single `pending` promise, else run the
385
+ * factory and cache. `pending` is cleared in `finally` so a rejected factory
386
+ * can be retried rather than caching the failure.
387
+ */
388
+ resolveFactoryCached(tokenId, descriptor, targetCtx) {
389
+ const generation = descriptor.generation;
390
+ const cached = targetCtx.getFactoryValue(tokenId);
391
+ if (cached?.generation === generation) return cached.value;
392
+ const pending = targetCtx.getFactoryPending(tokenId);
393
+ if (pending?.generation === generation) return pending.promise;
394
+ const creation = (async () => {
395
+ try {
396
+ const value = await this.runFactory(descriptor, targetCtx);
397
+ targetCtx.setFactoryValue(tokenId, generation, value);
398
+ return value;
399
+ } finally {
400
+ targetCtx.deleteFactoryPending(tokenId, generation);
401
+ }
402
+ })();
403
+ targetCtx.setFactoryPending(tokenId, generation, creation);
404
+ return creation;
405
+ }
406
+ /**
407
+ * Invoke a factory's function against the context that owns its lifecycle,
408
+ * with a best-effort synchronous re-entrancy guard.
409
+ *
410
+ * `executing` is set only for the synchronous duration of `fn(...)` — long
411
+ * enough to catch a factory that resolves its own token before returning (a
412
+ * synchronous self-cycle, or a synchronous mutual cycle between factories),
413
+ * but cleared the moment `fn` returns so that concurrent resolutions which
414
+ * legitimately coalesce on an in-flight result are never mistaken for a cycle.
415
+ *
416
+ * Known limitation: a factory that re-enters its own token *after* awaiting
417
+ * (an async cross-await self-reference) is indistinguishable from valid
418
+ * coalescing without async-context tracking, so that case is not caught here
419
+ * — it is documented in the factory-providers guide.
420
+ */
421
+ runFactory(descriptor, context) {
422
+ if (descriptor.executing) {
423
+ const label = descriptor.description ?? "<token>";
424
+ throw new Error(`Circular factory dependency detected: the factory for token ${label} resolved its own token while it was still being constructed. Break the cycle by restructuring the factory or resolving the dependency lazily.`);
425
+ }
426
+ descriptor.executing = true;
427
+ try {
428
+ return descriptor.fn(context);
429
+ } finally {
430
+ descriptor.executing = false;
431
+ }
432
+ }
433
+ /**
251
434
  * Format a readable representation of the resolution stack for error messages.
252
435
  */
253
436
  formatStackPath(target, resolutionStack) {
@@ -1,6 +1,6 @@
1
1
  import { Newable, Token } from "./types.js";
2
- import { Lazy } from "./lazy.js";
3
2
  import { ServiceScope } from "./scope.js";
3
+ import { Lazy } from "./lazy.js";
4
4
 
5
5
  //#region src/lib/decorators.d.ts
6
6
  /**
@@ -1,7 +1,7 @@
1
1
  import { Newable, Token } from "./types.js";
2
- import { Container } from "./container.js";
3
- import { Lazy } from "./lazy.js";
4
2
  import { ServiceScope } from "./scope.js";
3
+ import { Container, FactoryFn } from "./container.js";
4
+ import { Lazy } from "./lazy.js";
5
5
 
6
6
  //#region src/lib/providers.d.ts
7
7
  /**
@@ -56,11 +56,19 @@ interface LazyServiceProviderDescriptor<T = unknown> {
56
56
  lifecycle: ProviderLifecycle;
57
57
  deps?: DependenciesOption;
58
58
  }
59
+ /** Descriptor for a token-bound factory provider with an explicit lifecycle. */
60
+ interface FactoryProviderDescriptor<T = unknown> {
61
+ kind: "factory";
62
+ token: Token<T>;
63
+ factory: FactoryFn<T>;
64
+ lifecycle: ProviderLifecycle;
65
+ }
59
66
  /** Aggregate provider definitions for batch application. */
60
67
  interface ProviderDefinitions {
61
68
  values?: ValueProviderDescriptor[];
62
69
  services?: ServiceProviderDescriptor[];
63
70
  lazyServices?: LazyPlaceholder[];
71
+ factories?: FactoryProviderDescriptor[];
64
72
  }
65
73
  /**
66
74
  * Identity helper for provider definition blocks.
@@ -76,6 +84,22 @@ declare function asClass<T extends Newable<unknown>>(useClass: T, options: {
76
84
  lifecycle: ProviderLifecycle;
77
85
  deps?: DependenciesOption;
78
86
  }): ServiceProviderDescriptor<T>;
87
+ /**
88
+ * Create a factory provider: bind a token to a function executed at resolution
89
+ * time. Mirrors `asClass` — a token, a factory function, and a lifecycle.
90
+ *
91
+ * The factory receives the resolving context and may be async, so it can
92
+ * resolve its own dependencies. `singleton` caches the result on the root;
93
+ * `transient` re-runs on every resolution; a custom scope caches once per
94
+ * matching scope instance and disposes the result with it.
95
+ *
96
+ * @param token Token the produced value is bound to (its type checks `factory`'s return).
97
+ * @param factory Function invoked with the resolving context; may be async.
98
+ * @param options.lifecycle Lifecycle scope (singleton, transient, or a custom scope).
99
+ */
100
+ declare function asFactory<T>(token: Token<T>, factory: FactoryFn<T>, options: {
101
+ lifecycle: ProviderLifecycle;
102
+ }): FactoryProviderDescriptor<T>;
79
103
  /**
80
104
  * Convenience lifecycle helpers for fluent provider creation.
81
105
  */
@@ -108,4 +132,4 @@ declare function asLazyClass<T, const TDeps extends DependenciesOption>(importer
108
132
  */
109
133
  declare function applyProviders(container: Container, definitions: ProviderDefinitions | ProviderDefinitions[]): void;
110
134
  //#endregion
111
- export { ProviderDefinitions, applyProviders, asClass, asLazyClass, asValue, defineProviders, lifecycle };
135
+ export { FactoryProviderDescriptor, ProviderDefinitions, applyProviders, asClass, asFactory, asLazyClass, asValue, defineProviders, lifecycle };
@@ -34,6 +34,27 @@ function asClass(useClass, options) {
34
34
  };
35
35
  }
36
36
  /**
37
+ * Create a factory provider: bind a token to a function executed at resolution
38
+ * time. Mirrors `asClass` — a token, a factory function, and a lifecycle.
39
+ *
40
+ * The factory receives the resolving context and may be async, so it can
41
+ * resolve its own dependencies. `singleton` caches the result on the root;
42
+ * `transient` re-runs on every resolution; a custom scope caches once per
43
+ * matching scope instance and disposes the result with it.
44
+ *
45
+ * @param token Token the produced value is bound to (its type checks `factory`'s return).
46
+ * @param factory Function invoked with the resolving context; may be async.
47
+ * @param options.lifecycle Lifecycle scope (singleton, transient, or a custom scope).
48
+ */
49
+ function asFactory(token, factory, options) {
50
+ return {
51
+ kind: "factory",
52
+ token,
53
+ factory,
54
+ lifecycle: options.lifecycle
55
+ };
56
+ }
57
+ /**
37
58
  * Convenience lifecycle helpers for fluent provider creation.
38
59
  */
39
60
  const lifecycle = {
@@ -145,6 +166,7 @@ function applyProviders(container, definitions) {
145
166
  detectProviderCycles(planEntries);
146
167
  for (const definition of list) {
147
168
  for (const valueProvider of definition.values ?? []) container.provideValue(valueProvider.token, valueProvider.value);
169
+ for (const factoryProvider of definition.factories ?? []) container.provideFactory(factoryProvider.token, factoryProvider.factory, { lifecycle: factoryProvider.lifecycle });
148
170
  const registerService = (ctor, lifecycle, deps, factory) => {
149
171
  dependenciesRegistry.set(ctor, {
150
172
  scope: lifecycle,
@@ -161,4 +183,4 @@ function applyProviders(container, definitions) {
161
183
  }
162
184
  }
163
185
  //#endregion
164
- export { applyProviders, asClass, asLazyClass, asValue, defineProviders, lifecycle };
186
+ export { applyProviders, asClass, asFactory, asLazyClass, asValue, defineProviders, lifecycle };
@@ -1,8 +1,44 @@
1
+ import { Constructor } from "./types.js";
2
+
1
3
  //#region src/lib/scope.d.ts
2
4
  declare const ServiceScope: {
3
5
  readonly SINGLETON: "singleton";
4
6
  readonly TRANSIENT: "transient";
5
7
  };
6
- type ServiceScope = (typeof ServiceScope)[keyof typeof ServiceScope];
8
+ interface AlloyScopes {
9
+ singleton: true;
10
+ transient: true;
11
+ }
12
+ type ServiceScope = keyof AlloyScopes;
13
+ /**
14
+ * A cached factory result tagged with the `generation` of the descriptor that
15
+ * produced it. A mismatch against the current descriptor's generation (after a
16
+ * factory is re-registered) makes the entry stale, so it is recomputed.
17
+ */
18
+ interface FactoryCacheEntry {
19
+ generation: number;
20
+ value: unknown;
21
+ }
22
+ /** An in-flight factory resolution tagged with its descriptor generation. */
23
+ interface FactoryPendingEntry {
24
+ generation: number;
25
+ promise: Promise<unknown>;
26
+ }
27
+ interface ResolutionContext {
28
+ readonly scopeName: ServiceScope;
29
+ getCached(target: Constructor): unknown;
30
+ setCached(target: Constructor, instance: unknown): void;
31
+ getPending(target: Constructor): Promise<unknown> | undefined;
32
+ setPending(target: Constructor, promise: Promise<unknown>): void;
33
+ deletePending(target: Constructor): void;
34
+ getProvider(tokenId: symbol): unknown;
35
+ hasProvider(tokenId: symbol): boolean;
36
+ getFactoryValue(tokenId: symbol): FactoryCacheEntry | undefined;
37
+ setFactoryValue(tokenId: symbol, generation: number, value: unknown): void;
38
+ getFactoryPending(tokenId: symbol): FactoryPendingEntry | undefined;
39
+ setFactoryPending(tokenId: symbol, generation: number, promise: Promise<unknown>): void;
40
+ deleteFactoryPending(tokenId: symbol, generation: number): void;
41
+ readonly parent: ResolutionContext | null;
42
+ }
7
43
  //#endregion
8
- export { ServiceScope };
44
+ export { AlloyScopes, FactoryCacheEntry, FactoryPendingEntry, ResolutionContext, ServiceScope };