alloy-di 1.4.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.
@@ -1,8 +1,20 @@
1
1
  import { Constructor, Newable, Token } from "./types.js";
2
- import { ResolutionContext, ServiceScope } from "./scope.js";
2
+ import { FactoryCacheEntry, FactoryPendingEntry, ResolutionContext, ServiceScope } from "./scope.js";
3
3
  import { ServiceIdentifier } from "./service-identifiers.js";
4
4
 
5
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>;
6
18
  /**
7
19
  * Runtime dependency injection container used by generated modules and tests.
8
20
  *
@@ -15,6 +27,10 @@ declare class Container implements ResolutionContext {
15
27
  private readonly instanceOverrides;
16
28
  private readonly metadataCache;
17
29
  private readonly valueProviders;
30
+ private readonly factoryRegistry;
31
+ private readonly factoryValues;
32
+ private readonly factoryPending;
33
+ private factoryGeneration;
18
34
  private readonly factoryWarningCache;
19
35
  private readonly scopeHierarchy;
20
36
  readonly scopeName: ServiceScope;
@@ -26,6 +42,11 @@ declare class Container implements ResolutionContext {
26
42
  deletePending(target: Constructor): void;
27
43
  getProvider(tokenId: symbol): unknown;
28
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;
29
50
  /**
30
51
  * Resolve (and construct) the requested service.
31
52
  *
@@ -64,6 +85,23 @@ declare class Container implements ResolutionContext {
64
85
  * @param value - The value that should be injected when the token is requested.
65
86
  */
66
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;
67
105
  /**
68
106
  * Retrieve a provided value for a token from this container.
69
107
  * Throws if no provider is registered for the token.
@@ -125,9 +163,45 @@ declare class Container implements ResolutionContext {
125
163
  */
126
164
  private runImporterWithRetry;
127
165
  /**
128
- * Resolve a token dependency via registered value providers.
166
+ * Resolve a token dependency via registered value providers or factories.
129
167
  */
130
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;
131
205
  /**
132
206
  * Format a readable representation of the resolution stack for error messages.
133
207
  */
@@ -138,4 +212,4 @@ declare class Container implements ResolutionContext {
138
212
  private getServiceMetadata;
139
213
  }
140
214
  //#endregion
141
- export { Container };
215
+ export { Container, FactoryFn };
@@ -42,6 +42,10 @@ 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();
46
50
  scopeHierarchy = /* @__PURE__ */ new Map();
47
51
  scopeName = ServiceScope.SINGLETON;
@@ -67,6 +71,27 @@ var Container = class {
67
71
  hasProvider(tokenId) {
68
72
  return this.valueProviders.has(tokenId);
69
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
+ }
70
95
  async get(targetOrIdentifier) {
71
96
  if (typeof targetOrIdentifier === "symbol") return this.getByIdentifier(targetOrIdentifier);
72
97
  return this.getByConstructor(targetOrIdentifier, this);
@@ -123,11 +148,38 @@ var Container = class {
123
148
  this.valueProviders.set(token.id, value);
124
149
  }
125
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
+ /**
126
174
  * Retrieve a provided value for a token from this container.
127
175
  * Throws if no provider is registered for the token.
128
176
  */
129
177
  getToken(token) {
130
- 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
+ }
131
183
  return this.valueProviders.get(token.id);
132
184
  }
133
185
  async getByConstructor(target, context, options) {
@@ -292,7 +344,7 @@ var Container = class {
292
344
  }
293
345
  }
294
346
  /**
295
- * Resolve a token dependency via registered value providers.
347
+ * Resolve a token dependency via registered value providers or factories.
296
348
  */
297
349
  resolveTokenLike(tok, target, resolutionStack, context) {
298
350
  let current = context;
@@ -300,6 +352,8 @@ var Container = class {
300
352
  if (current.hasProvider(tok.id)) return current.getProvider(tok.id);
301
353
  current = current.parent;
302
354
  }
355
+ const factory = this.factoryRegistry.get(tok.id);
356
+ if (factory) return this.resolveFactory(tok.id, factory, context);
303
357
  const stackPath = this.formatStackPath(target, resolutionStack);
304
358
  throw new DependencyResolutionError(`No provider registered for token ${tok.description ?? String(tok.id)} while resolving ${target.name}. Resolution stack: ${stackPath}`, {
305
359
  target,
@@ -308,6 +362,75 @@ var Container = class {
308
362
  });
309
363
  }
310
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
+ /**
311
434
  * Format a readable representation of the resolution stack for error messages.
312
435
  */
313
436
  formatStackPath(target, resolutionStack) {
@@ -1,6 +1,6 @@
1
1
  import { Newable, Token } from "./types.js";
2
2
  import { ServiceScope } from "./scope.js";
3
- import { Container } from "./container.js";
3
+ import { Container, FactoryFn } from "./container.js";
4
4
  import { Lazy } from "./lazy.js";
5
5
 
6
6
  //#region src/lib/providers.d.ts
@@ -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 };
@@ -10,6 +10,20 @@ interface AlloyScopes {
10
10
  transient: true;
11
11
  }
12
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
+ }
13
27
  interface ResolutionContext {
14
28
  readonly scopeName: ServiceScope;
15
29
  getCached(target: Constructor): unknown;
@@ -19,7 +33,12 @@ interface ResolutionContext {
19
33
  deletePending(target: Constructor): void;
20
34
  getProvider(tokenId: symbol): unknown;
21
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;
22
41
  readonly parent: ResolutionContext | null;
23
42
  }
24
43
  //#endregion
25
- export { AlloyScopes, ResolutionContext, ServiceScope };
44
+ export { AlloyScopes, FactoryCacheEntry, FactoryPendingEntry, ResolutionContext, ServiceScope };
@@ -381,8 +381,8 @@ function generateContainerTypeDefinition(metas, pathResolver) {
381
381
  for (const meta of metas) {
382
382
  const importName = pool.claim(resolver.resolve(meta.className, meta.filePath));
383
383
  const importPath = pathResolver(meta.filePath);
384
- if (importName === meta.className) imports.push(`import { ${meta.className} } from '${importPath}';`);
385
- else imports.push(`import { ${meta.className} as ${importName} } from '${importPath}';`);
384
+ if (importName === meta.className) imports.push(` import { ${meta.className} } from '${importPath}';`);
385
+ else imports.push(` import { ${meta.className} as ${importName} } from '${importPath}';`);
386
386
  const exportKey = createIdentifierExportKey(meta, resolver);
387
387
  interfaceMembers.push(`${exportKey}: ServiceIdentifier<${importName}>;`);
388
388
  }
@@ -42,7 +42,7 @@ async function loadVirtualContainerModule(options) {
42
42
  scopes: options.scopes
43
43
  });
44
44
  writeTypeDefinitions(metas, loadedManifests, options.resolvedRoot, options.containerDeclarationDir, options.scopes);
45
- writeVisualizationArtifact(metas, lazyClassKeys, options.resolvedVisualization, options.scopes);
45
+ writeVisualizationArtifact(metas, lazyClassKeys, options.factoryProviders, options.resolvedVisualization, options.scopes);
46
46
  return {
47
47
  code,
48
48
  moduleType: "js"
@@ -93,11 +93,12 @@ function resolveDeclarationImportPath(dtsDir, filePath) {
93
93
  if (!rel.startsWith(".")) rel = "./" + rel;
94
94
  return rel;
95
95
  }
96
- function writeVisualizationArtifact(metas, lazyReferencedClassKeys, resolvedVisualization, scopes) {
96
+ function writeVisualizationArtifact(metas, lazyReferencedClassKeys, factoryProviders, resolvedVisualization, scopes) {
97
97
  if (!resolvedVisualization) return;
98
98
  const artifact = generateMermaidDiagram({
99
99
  metas,
100
100
  lazyClassKeys: new Set(lazyReferencedClassKeys),
101
+ factoryProviders,
101
102
  options: resolvedVisualization.mermaidOptions,
102
103
  scopes
103
104
  });
@@ -34,31 +34,46 @@ function lazyKeysSignature(keys) {
34
34
  if (!keys || keys.size === 0) return "";
35
35
  return Array.from(keys).toSorted().join("|");
36
36
  }
37
+ function factoryProvidersSignature(providers) {
38
+ return JSON.stringify((providers ?? []).map((provider) => ({
39
+ filePath: provider.filePath,
40
+ tokenExpression: provider.tokenExpression,
41
+ tokenLabel: provider.tokenLabel,
42
+ lifecycle: provider.lifecycle
43
+ })));
44
+ }
37
45
  function createDiscoveryRuntime() {
38
46
  const discovery = createDiscoveryStore();
39
47
  const discoveredClasses = /* @__PURE__ */ new Map();
40
48
  const lazyReferencedClassKeys = /* @__PURE__ */ new Set();
49
+ const factoryProvidersByFile = /* @__PURE__ */ new Map();
41
50
  return {
42
51
  discoveredClasses,
43
52
  lazyReferencedClassKeys,
44
- processUpdate(id, code) {
45
- const { metas, lazyClassKeys, previousMetas, previousLazyClassKeys } = discovery.updateFile(id, code);
53
+ factoryProvidersByFile,
54
+ processUpdate(id, code, options) {
55
+ const trackFactoryProviders = options?.factoryProviders ?? true;
56
+ const { metas, lazyClassKeys, factoryProviders, previousMetas, previousLazyClassKeys, previousFactoryProviders } = discovery.updateFile(id, code, { factoryProviders: trackFactoryProviders });
46
57
  if (previousMetas) for (const meta of previousMetas) discoveredClasses.delete(createClassKey(meta.filePath, meta.className));
47
58
  for (const meta of metas) discoveredClasses.set(createClassKey(meta.filePath, meta.className), meta);
48
59
  if (previousLazyClassKeys) for (const key of previousLazyClassKeys) lazyReferencedClassKeys.delete(key);
49
60
  if (lazyClassKeys.size) for (const key of lazyClassKeys) lazyReferencedClassKeys.add(key);
50
- return metasSignature(previousMetas ?? []) !== metasSignature(metas) || lazyKeysSignature(previousLazyClassKeys) !== lazyKeysSignature(lazyClassKeys);
61
+ if (trackFactoryProviders && factoryProviders.length) factoryProvidersByFile.set(id, factoryProviders);
62
+ else factoryProvidersByFile.delete(id);
63
+ return metasSignature(previousMetas ?? []) !== metasSignature(metas) || lazyKeysSignature(previousLazyClassKeys) !== lazyKeysSignature(lazyClassKeys) || trackFactoryProviders && factoryProvidersSignature(previousFactoryProviders) !== factoryProvidersSignature(factoryProviders);
51
64
  },
52
65
  removeDiscoveredFile(file) {
53
66
  const removed = discovery.removeFile(file);
54
67
  if (removed.previousMetas) for (const meta of removed.previousMetas) discoveredClasses.delete(createClassKey(meta.filePath, meta.className));
55
68
  if (removed.previousLazyClassKeys) for (const key of removed.previousLazyClassKeys) lazyReferencedClassKeys.delete(key);
56
- return Boolean(removed.previousMetas?.length || removed.previousLazyClassKeys?.size);
69
+ factoryProvidersByFile.delete(file);
70
+ return Boolean(removed.previousMetas?.length || removed.previousLazyClassKeys?.size || removed.previousFactoryProviders?.length);
57
71
  },
58
72
  clear() {
59
73
  discovery.clear();
60
74
  discoveredClasses.clear();
61
75
  lazyReferencedClassKeys.clear();
76
+ factoryProvidersByFile.clear();
62
77
  }
63
78
  };
64
79
  }
@@ -19,6 +19,7 @@ function hashContent(code) {
19
19
  function createDiscoveryStore(options = {}) {
20
20
  const fileMetas = /* @__PURE__ */ new Map();
21
21
  const fileLazyRefs = /* @__PURE__ */ new Map();
22
+ const fileFactoryProviders = /* @__PURE__ */ new Map();
22
23
  const fileContentHashes = /* @__PURE__ */ new Map();
23
24
  const fileSources = options.trackSources ? /* @__PURE__ */ new Map() : void 0;
24
25
  /**
@@ -28,28 +29,36 @@ function createDiscoveryStore(options = {}) {
28
29
  * @param id - Module identifier or path.
29
30
  * @param code - Current file contents to analyze.
30
31
  */
31
- function updateFile(id, code) {
32
+ function updateFile(id, code, options) {
33
+ const scanFactoryProviders = options?.factoryProviders ?? true;
32
34
  const previousMetas = fileMetas.get(id);
33
35
  const previousLazyClassKeys = fileLazyRefs.get(id);
34
- const contentHash = hashContent(code);
36
+ const previousFactoryProviders = fileFactoryProviders.get(id);
37
+ const contentHash = hashContent(`${scanFactoryProviders ? "factory:1" : "factory:0"}\0${code}`);
35
38
  if (fileContentHashes.get(id) === contentHash) return {
36
39
  metas: previousMetas ?? [],
37
40
  lazyClassKeys: new Set(previousLazyClassKeys),
41
+ factoryProviders: previousFactoryProviders ?? [],
38
42
  previousMetas,
39
- previousLazyClassKeys
43
+ previousLazyClassKeys,
44
+ previousFactoryProviders
40
45
  };
41
46
  fileContentHashes.set(id, contentHash);
42
47
  if (fileSources) fileSources.set(id, code);
43
- const { metas, lazyClassKeys } = scanSource(code, id);
48
+ const { metas, lazyClassKeys, factoryProviders } = scanSource(code, id, { factoryProviders: scanFactoryProviders });
44
49
  if (metas.length) fileMetas.set(id, metas);
45
50
  else fileMetas.delete(id);
46
51
  if (lazyClassKeys.size) fileLazyRefs.set(id, lazyClassKeys);
47
52
  else fileLazyRefs.delete(id);
53
+ if (factoryProviders.length) fileFactoryProviders.set(id, factoryProviders);
54
+ else fileFactoryProviders.delete(id);
48
55
  return {
49
56
  metas,
50
57
  lazyClassKeys,
58
+ factoryProviders,
51
59
  previousMetas,
52
- previousLazyClassKeys
60
+ previousLazyClassKeys,
61
+ previousFactoryProviders
53
62
  };
54
63
  }
55
64
  /**
@@ -60,13 +69,16 @@ function createDiscoveryStore(options = {}) {
60
69
  function removeFile(id) {
61
70
  const previousMetas = fileMetas.get(id);
62
71
  const previousLazyClassKeys = fileLazyRefs.get(id);
72
+ const previousFactoryProviders = fileFactoryProviders.get(id);
63
73
  fileMetas.delete(id);
64
74
  fileLazyRefs.delete(id);
75
+ fileFactoryProviders.delete(id);
65
76
  fileContentHashes.delete(id);
66
77
  if (fileSources) fileSources.delete(id);
67
78
  return {
68
79
  previousMetas,
69
- previousLazyClassKeys
80
+ previousLazyClassKeys,
81
+ previousFactoryProviders
70
82
  };
71
83
  }
72
84
  /**
@@ -75,12 +87,14 @@ function createDiscoveryStore(options = {}) {
75
87
  function clear() {
76
88
  fileMetas.clear();
77
89
  fileLazyRefs.clear();
90
+ fileFactoryProviders.clear();
78
91
  fileContentHashes.clear();
79
92
  fileSources?.clear();
80
93
  }
81
94
  return {
82
95
  fileMetas,
83
96
  fileLazyRefs,
97
+ fileFactoryProviders,
84
98
  fileSources,
85
99
  updateFile,
86
100
  removeFile,
@@ -1,4 +1,5 @@
1
1
  import { createClassKey, createSymbolKey } from "./utils.js";
2
+ import { ServiceScope } from "../../lib/scope.js";
2
3
  import { extractServiceMetadata } from "./decorators.js";
3
4
  import { processLazyCall, resolveModuleSpecifierCandidates } from "./lazy.js";
4
5
  import fs from "node:fs";
@@ -41,17 +42,23 @@ function collectFileImports(sourceFile) {
41
42
  * discovery results: decorators require an `@` and lazy references require
42
43
  * the `Lazy` identifier.
43
44
  */
44
- function mayContainDiscoverableSyntax(code) {
45
- return code.includes("@") || code.includes("Lazy");
45
+ function shouldScanFactoryProviders(options) {
46
+ return options?.factoryProviders ?? true;
46
47
  }
47
- function scanSource(code, id) {
48
- if (!mayContainDiscoverableSyntax(code)) return {
48
+ function mayContainDiscoverableSyntax(code, options) {
49
+ return code.includes("@") || code.includes("Lazy") || shouldScanFactoryProviders(options) && code.includes("asFactory");
50
+ }
51
+ function scanSource(code, id, options) {
52
+ const scanFactoryProviders = shouldScanFactoryProviders(options);
53
+ if (!mayContainDiscoverableSyntax(code, options)) return {
49
54
  metas: [],
50
- lazyClassKeys: /* @__PURE__ */ new Set()
55
+ lazyClassKeys: /* @__PURE__ */ new Set(),
56
+ factoryProviders: []
51
57
  };
52
58
  const sourceFile = ts.createSourceFile(id, code, ts.ScriptTarget.ESNext, true);
53
59
  const discovered = /* @__PURE__ */ new Map();
54
60
  const lazyRefs = /* @__PURE__ */ new Set();
61
+ const factoryProviders = [];
55
62
  const fileImports = collectFileImports(sourceFile);
56
63
  const decoratorResolutionCache = /* @__PURE__ */ new Map();
57
64
  const visit = (node) => {
@@ -62,15 +69,72 @@ function scanSource(code, id) {
62
69
  discovered,
63
70
  decoratorResolutionCache
64
71
  });
65
- else if (ts.isCallExpression(node)) processLazyCall(node, id, sourceFile, lazyRefs);
72
+ else if (ts.isCallExpression(node)) {
73
+ processLazyCall(node, id, sourceFile, lazyRefs);
74
+ if (scanFactoryProviders) handleFactoryProviderCall(node, {
75
+ id,
76
+ sourceFile,
77
+ fileImports,
78
+ factoryProviders
79
+ });
80
+ }
66
81
  ts.forEachChild(node, visit);
67
82
  };
68
83
  ts.forEachChild(sourceFile, visit);
69
84
  return {
70
85
  metas: Array.from(discovered.values()),
71
- lazyClassKeys: lazyRefs
86
+ lazyClassKeys: lazyRefs,
87
+ factoryProviders
72
88
  };
73
89
  }
90
+ function handleFactoryProviderCall(node, context) {
91
+ if (!isAlloyRuntimeHelper(node.expression, "asFactory", context.fileImports)) return;
92
+ const tokenArg = node.arguments[0];
93
+ if (!tokenArg) return;
94
+ context.factoryProviders.push({
95
+ filePath: context.id,
96
+ tokenExpression: tokenArg.getText(context.sourceFile),
97
+ tokenLabel: createFactoryTokenLabel(tokenArg, context.sourceFile),
98
+ lifecycle: extractFactoryLifecycle(node.arguments[2])
99
+ });
100
+ }
101
+ function isAlloyRuntimeHelper(expression, helperName, fileImports) {
102
+ if (ts.isIdentifier(expression)) {
103
+ const importInfo = fileImports.get(expression.text);
104
+ return Boolean(importInfo && !importInfo.isTypeOnly && importInfo.path === ALLOY_RUNTIME_MODULE && (importInfo.originalName ?? expression.text) === helperName);
105
+ }
106
+ if (ts.isPropertyAccessExpression(expression) && ts.isIdentifier(expression.expression)) {
107
+ const importInfo = fileImports.get(expression.expression.text);
108
+ return Boolean(importInfo && !importInfo.isTypeOnly && importInfo.path === ALLOY_RUNTIME_MODULE && importInfo.originalName === "*" && expression.name.text === helperName);
109
+ }
110
+ return false;
111
+ }
112
+ function createFactoryTokenLabel(tokenArg, sourceFile) {
113
+ if (ts.isIdentifier(tokenArg)) return tokenArg.text;
114
+ return tokenArg.getText(sourceFile);
115
+ }
116
+ function extractFactoryLifecycle(optionsArg) {
117
+ if (!optionsArg || !ts.isObjectLiteralExpression(optionsArg)) return ServiceScope.SINGLETON;
118
+ for (const prop of optionsArg.properties) {
119
+ if (!ts.isPropertyAssignment(prop) || !ts.isIdentifier(prop.name)) continue;
120
+ if (prop.name.text !== "lifecycle") continue;
121
+ return lifecycleExpressionToScope(prop.initializer);
122
+ }
123
+ return ServiceScope.SINGLETON;
124
+ }
125
+ function lifecycleExpressionToScope(expression) {
126
+ if (ts.isStringLiteralLike(expression)) return expression.text;
127
+ if (ts.isCallExpression(expression)) {
128
+ const callTarget = expression.expression;
129
+ if (ts.isPropertyAccessExpression(callTarget) && (callTarget.name.text === "singleton" || callTarget.name.text === "transient")) return callTarget.name.text;
130
+ }
131
+ if (ts.isPropertyAccessExpression(expression)) {
132
+ const member = expression.name.text;
133
+ if (member === "SINGLETON") return ServiceScope.SINGLETON;
134
+ if (member === "TRANSIENT") return ServiceScope.TRANSIENT;
135
+ }
136
+ return ServiceScope.SINGLETON;
137
+ }
74
138
  function handleClassDeclaration(node, context) {
75
139
  if (!node.name) return;
76
140
  const decoratorMatch = findServiceDecorator(node, context.sourceFile, context.fileImports, context.id, context.decoratorResolutionCache);
@@ -49,7 +49,7 @@ const RESERVED_IDENTIFIERS = new Set([
49
49
  * @param input - Discovered metadata, optional lazy keys, and rendering options.
50
50
  * @returns The rendered diagram plus simple counts useful for reporting.
51
51
  */
52
- function generateMermaidDiagram({ metas, lazyClassKeys, options, scopes }) {
52
+ function generateMermaidDiagram({ metas, lazyClassKeys, factoryProviders, options, scopes }) {
53
53
  const mergedOptions = {
54
54
  ...DEFAULT_OPTIONS,
55
55
  ...options,
@@ -69,6 +69,7 @@ function generateMermaidDiagram({ metas, lazyClassKeys, options, scopes }) {
69
69
  const nodesByClassName = /* @__PURE__ */ new Map();
70
70
  const nodesByFilePath = /* @__PURE__ */ new Map();
71
71
  const tokenNodes = /* @__PURE__ */ new Map();
72
+ const factoryNodes = /* @__PURE__ */ new Map();
72
73
  const nodeByMeta = /* @__PURE__ */ new Map();
73
74
  metas.forEach((meta, index) => {
74
75
  const key = createClassKey(meta.filePath, meta.className);
@@ -94,6 +95,7 @@ function generateMermaidDiagram({ metas, lazyClassKeys, options, scopes }) {
94
95
  pathBucket.push(node);
95
96
  nodesByFilePath.set(normalizedPath, pathBucket);
96
97
  });
98
+ for (const provider of factoryProviders ?? []) ensureFactoryNode(factoryNodes, provider);
97
99
  const edges = [];
98
100
  const edgeKeys = /* @__PURE__ */ new Set();
99
101
  metas.forEach((meta) => {
@@ -105,7 +107,7 @@ function generateMermaidDiagram({ metas, lazyClassKeys, options, scopes }) {
105
107
  if (!identifiers.length) continue;
106
108
  const targets = /* @__PURE__ */ new Map();
107
109
  for (const ident of identifiers) {
108
- const resolvedTargets = resolveTargetsForIdentifier(ident, dep.expression, meta, nodesByClassName, nodesByFilePath, tokenNodes);
110
+ const resolvedTargets = resolveTargetsForIdentifier(ident, dep.expression, meta, nodesByClassName, nodesByFilePath, tokenNodes, factoryNodes);
109
111
  for (const target of resolvedTargets) {
110
112
  if (target.id === sourceNode.id) continue;
111
113
  targets.set(target.id, target);
@@ -140,10 +142,14 @@ function generateMermaidDiagram({ metas, lazyClassKeys, options, scopes }) {
140
142
  lines.push(` %% Scope stability: ⚠️ ${VIOLATION_EDGE_COLOR} edge = captive dependency (longer-lived service depends on shorter-lived scope)`);
141
143
  }
142
144
  }
143
- const allNodes = [...serviceNodes, ...Array.from(tokenNodes.values())];
145
+ const allNodes = [
146
+ ...serviceNodes,
147
+ ...Array.from(factoryNodes.values()),
148
+ ...Array.from(tokenNodes.values())
149
+ ];
144
150
  const groupedNodes = /* @__PURE__ */ new Map();
145
151
  const ungroupedNodes = [];
146
- for (const node of allNodes) if (node.type === "service" && node.scope && customScopes.has(node.scope)) {
152
+ for (const node of allNodes) if ((node.type === "service" || node.type === "factory") && node.scope && customScopes.has(node.scope)) {
147
153
  const bucket = groupedNodes.get(node.scope) ?? [];
148
154
  bucket.push(node);
149
155
  groupedNodes.set(node.scope, bucket);
@@ -179,7 +185,8 @@ function generateMermaidDiagram({ metas, lazyClassKeys, options, scopes }) {
179
185
  diagram: lines.join("\n"),
180
186
  nodeCount: allNodes.length,
181
187
  edgeCount: edges.length,
182
- tokenCount: tokenNodes.size
188
+ tokenCount: tokenNodes.size,
189
+ factoryCount: factoryNodes.size
183
190
  };
184
191
  }
185
192
  /**
@@ -216,12 +223,14 @@ function scopeCode(node) {
216
223
  }
217
224
  /**
218
225
  * True when an edge captures a shorter-lived dependency in a longer-lived host,
219
- * per the declared hierarchy. Only service-to-service edges with known scopes
220
- * are considered, and only when a custom hierarchy is configured.
226
+ * per the declared hierarchy. Service dependencies on scoped factory providers
227
+ * are checked too: factory bodies are opaque, but the factory result lifecycle
228
+ * is known and can still be captive.
221
229
  */
222
230
  function isStabilityViolation(from, to, scopes) {
223
231
  if (!scopes) return false;
224
- if (from.type !== "service" || to.type !== "service") return false;
232
+ if (from.type !== "service") return false;
233
+ if (to.type !== "service" && to.type !== "factory") return false;
225
234
  if (!from.scope || !to.scope) return false;
226
235
  return !isDependencyAllowed(from.scope, to.scope, scopes);
227
236
  }
@@ -238,6 +247,7 @@ function describeEdge(from, to) {
238
247
  */
239
248
  function nodeFill(node, opts) {
240
249
  if (node.type === "token") return `fill:${opts.tokenNodeFill}`;
250
+ if (node.type === "factory") return `fill:${opts.factoryNodeFill}`;
241
251
  if (node.hasFactory) return `fill:${opts.factoryNodeFill}`;
242
252
  if (node.isLazyOnly) return `fill:${opts.lazyNodeFill}`;
243
253
  const scopeFill = node.scope ? opts.scopeColors[node.scope] : void 0;
@@ -278,14 +288,17 @@ function inferIdentifiersFromExpression(expression) {
278
288
  /**
279
289
  * Resolves a dependency identifier to known service nodes, or creates a token node when unresolved.
280
290
  */
281
- function resolveTargetsForIdentifier(identifier, fallbackExpression, meta, nodesByClassName, nodesByFilePath, tokenNodes) {
291
+ function resolveTargetsForIdentifier(identifier, fallbackExpression, meta, nodesByClassName, nodesByFilePath, tokenNodes, factoryNodes) {
282
292
  const serviceMatches = resolveServiceTargets(identifier, meta, nodesByClassName, nodesByFilePath);
283
293
  if (serviceMatches.length) {
284
294
  const deduped = /* @__PURE__ */ new Map();
285
295
  for (const node of serviceMatches) deduped.set(node.id, node);
286
296
  return Array.from(deduped.values());
287
297
  }
288
- return [ensureTokenNode(tokenNodes, createTokenLabel(identifier || fallbackExpression))];
298
+ const tokenLabel = createTokenLabel(identifier || fallbackExpression);
299
+ const factoryNode = factoryNodes.get(tokenLabel);
300
+ if (factoryNode) return [factoryNode];
301
+ return [ensureTokenNode(tokenNodes, tokenLabel)];
289
302
  }
290
303
  /**
291
304
  * Attempts to find service nodes that match an identifier via import metadata or class names.
@@ -340,6 +353,23 @@ function ensureTokenNode(tokenNodes, label) {
340
353
  tokenNodes.set(label, node);
341
354
  return node;
342
355
  }
356
+ function ensureFactoryNode(factoryNodes, provider) {
357
+ const label = createTokenLabel(provider.tokenLabel || provider.tokenExpression);
358
+ const existing = factoryNodes.get(label);
359
+ if (existing) return existing;
360
+ const node = {
361
+ id: sanitizeMermaidId(`factory:${provider.filePath}:${provider.tokenExpression}`, factoryNodes.size),
362
+ label: `Factory: ${label}`,
363
+ key: label,
364
+ scope: provider.lifecycle,
365
+ type: "factory",
366
+ isLazyOnly: false,
367
+ hasFactory: true,
368
+ filePath: normalizeImportPath(provider.filePath)
369
+ };
370
+ factoryNodes.set(label, node);
371
+ return node;
372
+ }
343
373
  /**
344
374
  * Condenses arbitrary strings into stable token labels, truncating overly long values.
345
375
  */
@@ -26,6 +26,7 @@ function alloy(options = {}) {
26
26
  let isDevMode;
27
27
  const lazyServiceKeys = new Set((options.lazyServices ?? []).map(toLazyServiceKey));
28
28
  const discoveryRuntime = createDiscoveryRuntime();
29
+ const shouldTrackFactoryProviders = () => Boolean(resolvedVisualization);
29
30
  return {
30
31
  name: "vite-plugin-alloy",
31
32
  enforce: "pre",
@@ -59,7 +60,7 @@ function alloy(options = {}) {
59
60
  exclude: [/\.d\.ts$/i, /node_modules/]
60
61
  } },
61
62
  handler(code, id) {
62
- discoveryRuntime.processUpdate(id, code);
63
+ discoveryRuntime.processUpdate(id, code, { factoryProviders: shouldTrackFactoryProviders() });
63
64
  return null;
64
65
  }
65
66
  },
@@ -76,7 +77,7 @@ function alloy(options = {}) {
76
77
  } catch {
77
78
  return;
78
79
  }
79
- discoveryChanged = discoveryRuntime.processUpdate(file, code);
80
+ discoveryChanged = discoveryRuntime.processUpdate(file, code, { factoryProviders: shouldTrackFactoryProviders() });
80
81
  }
81
82
  if (!discoveryChanged) return;
82
83
  invalidateContainerModule(ctx.server, resolvedVirtualModuleId);
@@ -85,11 +86,17 @@ function alloy(options = {}) {
85
86
  },
86
87
  buildStart() {
87
88
  discoveryRuntime.clear();
88
- for (const ref of providerModuleRefs) this.addWatchFile(ref.absPath);
89
+ for (const ref of providerModuleRefs) {
90
+ this.addWatchFile(ref.absPath);
91
+ if (shouldTrackFactoryProviders()) try {
92
+ const code = fs.readFileSync(ref.absPath, "utf-8");
93
+ discoveryRuntime.processUpdate(ref.absPath, code, { factoryProviders: true });
94
+ } catch {}
95
+ }
89
96
  const files = walkSync(path.join(resolvedRoot, "src"));
90
97
  for (const file of files) if (/\.(tsx?|ts)$/i.test(file) && !file.endsWith(".d.ts")) try {
91
98
  const code = fs.readFileSync(file, "utf-8");
92
- discoveryRuntime.processUpdate(file, code);
99
+ discoveryRuntime.processUpdate(file, code, { factoryProviders: shouldTrackFactoryProviders() });
93
100
  } catch {}
94
101
  },
95
102
  load: {
@@ -101,6 +108,7 @@ function alloy(options = {}) {
101
108
  lazyReferencedClassKeys: discoveryRuntime.lazyReferencedClassKeys,
102
109
  manifests: options.manifests ?? [],
103
110
  providerImportPaths: providerModuleRefs.map((ref) => ref.importPath),
111
+ factoryProviders: shouldTrackFactoryProviders() ? Array.from(discoveryRuntime.factoryProvidersByFile.values()).flat() : [],
104
112
  lazyServiceKeys,
105
113
  packageName,
106
114
  resolvedRoot,
package/dist/runtime.d.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  import { Newable, Token, createToken } from "./lib/types.js";
2
2
  import { AlloyScopes, ResolutionContext, ServiceScope } from "./lib/scope.js";
3
3
  import { ServiceIdentifier, clearServiceIdentifierRegistry, getConstructorByIdentifier, getServiceIdentifier, registerServiceIdentifier } from "./lib/service-identifiers.js";
4
- import { Container } from "./lib/container.js";
4
+ import { Container, FactoryFn } from "./lib/container.js";
5
5
  import { EnvDetectionOverrides, setEnvDetectionOverrides } from "./lib/env-detection.js";
6
6
  import { LAZY_IDENTIFIER, Lazy } from "./lib/lazy.js";
7
7
  import { Injectable, Singleton, assertDeps, dependenciesRegistry, deps } from "./lib/decorators.js";
8
- import { ProviderDefinitions, applyProviders, asClass, asLazyClass, asValue, defineProviders, lifecycle } from "./lib/providers.js";
9
- export { type AlloyScopes, Container, type EnvDetectionOverrides, Injectable, LAZY_IDENTIFIER, Lazy, type Lazy as LazyInterface, type Newable, type ProviderDefinitions, type ResolutionContext, type ServiceIdentifier, ServiceScope, Singleton, type Token, applyProviders, asClass, asLazyClass, asValue, assertDeps, clearServiceIdentifierRegistry, createToken, defineProviders, dependenciesRegistry, deps, getConstructorByIdentifier, getServiceIdentifier, lifecycle, registerServiceIdentifier, setEnvDetectionOverrides };
8
+ import { FactoryProviderDescriptor, ProviderDefinitions, applyProviders, asClass, asFactory, asLazyClass, asValue, defineProviders, lifecycle } from "./lib/providers.js";
9
+ export { type AlloyScopes, Container, type EnvDetectionOverrides, type FactoryFn, type FactoryProviderDescriptor, Injectable, LAZY_IDENTIFIER, Lazy, type Lazy as LazyInterface, type Newable, type ProviderDefinitions, type ResolutionContext, type ServiceIdentifier, ServiceScope, Singleton, type Token, applyProviders, asClass, asFactory, asLazyClass, asValue, assertDeps, clearServiceIdentifierRegistry, createToken, defineProviders, dependenciesRegistry, deps, getConstructorByIdentifier, getServiceIdentifier, lifecycle, registerServiceIdentifier, setEnvDetectionOverrides };
package/dist/runtime.js CHANGED
@@ -5,5 +5,5 @@ import { Injectable, Singleton, assertDeps, dependenciesRegistry, deps } from ".
5
5
  import { clearServiceIdentifierRegistry, getConstructorByIdentifier, getServiceIdentifier, registerServiceIdentifier } from "./lib/service-identifiers.js";
6
6
  import { setEnvDetectionOverrides } from "./lib/env-detection.js";
7
7
  import { Container } from "./lib/container.js";
8
- import { applyProviders, asClass, asLazyClass, asValue, defineProviders, lifecycle } from "./lib/providers.js";
9
- export { Container, Injectable, LAZY_IDENTIFIER, Lazy, ServiceScope, Singleton, applyProviders, asClass, asLazyClass, asValue, assertDeps, clearServiceIdentifierRegistry, createToken, defineProviders, dependenciesRegistry, deps, getConstructorByIdentifier, getServiceIdentifier, lifecycle, registerServiceIdentifier, setEnvDetectionOverrides };
8
+ import { applyProviders, asClass, asFactory, asLazyClass, asValue, defineProviders, lifecycle } from "./lib/providers.js";
9
+ export { Container, Injectable, LAZY_IDENTIFIER, Lazy, ServiceScope, Singleton, applyProviders, asClass, asFactory, asLazyClass, asValue, assertDeps, clearServiceIdentifierRegistry, createToken, defineProviders, dependenciesRegistry, deps, getConstructorByIdentifier, getServiceIdentifier, lifecycle, registerServiceIdentifier, setEnvDetectionOverrides };
package/dist/scopes.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { Constructor, Newable, Token } from "./lib/types.js";
2
- import { ResolutionContext, ServiceScope } from "./lib/scope.js";
2
+ import { FactoryCacheEntry, FactoryPendingEntry, ResolutionContext, ServiceScope } from "./lib/scope.js";
3
3
  import { ServiceIdentifier } from "./lib/service-identifiers.js";
4
4
  import { Container } from "./lib/container.js";
5
5
 
@@ -14,6 +14,8 @@ declare class Scope implements ResolutionContext {
14
14
  private readonly cached;
15
15
  private readonly pending;
16
16
  private readonly valueProviders;
17
+ private readonly factoryValues;
18
+ private readonly factoryPending;
17
19
  private readonly activeChildren;
18
20
  private readonly instantiatedInstances;
19
21
  constructor(parent: ResolutionContext, scopeName: ServiceScope);
@@ -29,6 +31,11 @@ declare class Scope implements ResolutionContext {
29
31
  deletePending(target: Constructor): void;
30
32
  getProvider(tokenId: symbol): unknown;
31
33
  hasProvider(tokenId: symbol): boolean;
34
+ getFactoryValue(tokenId: symbol): FactoryCacheEntry | undefined;
35
+ setFactoryValue(tokenId: symbol, generation: number, value: unknown): void;
36
+ getFactoryPending(tokenId: symbol): FactoryPendingEntry | undefined;
37
+ setFactoryPending(tokenId: symbol, generation: number, promise: Promise<unknown>): void;
38
+ deleteFactoryPending(tokenId: symbol, generation: number): void;
32
39
  /**
33
40
  * Register a value provider for a token scoped to this context.
34
41
  */
package/dist/scopes.js CHANGED
@@ -9,6 +9,8 @@ var Scope = class Scope {
9
9
  cached = /* @__PURE__ */ new Map();
10
10
  pending = /* @__PURE__ */ new Map();
11
11
  valueProviders = /* @__PURE__ */ new Map();
12
+ factoryValues = /* @__PURE__ */ new Map();
13
+ factoryPending = /* @__PURE__ */ new Map();
12
14
  activeChildren = /* @__PURE__ */ new Set();
13
15
  instantiatedInstances = [];
14
16
  constructor(parent, scopeName) {
@@ -48,6 +50,28 @@ var Scope = class Scope {
48
50
  if (this.valueProviders.has(tokenId)) return true;
49
51
  return this.parent.hasProvider(tokenId);
50
52
  }
53
+ getFactoryValue(tokenId) {
54
+ return this.factoryValues.get(tokenId);
55
+ }
56
+ setFactoryValue(tokenId, generation, value) {
57
+ this.factoryValues.set(tokenId, {
58
+ generation,
59
+ value
60
+ });
61
+ this.instantiatedInstances.push(value);
62
+ }
63
+ getFactoryPending(tokenId) {
64
+ return this.factoryPending.get(tokenId);
65
+ }
66
+ setFactoryPending(tokenId, generation, promise) {
67
+ this.factoryPending.set(tokenId, {
68
+ generation,
69
+ promise
70
+ });
71
+ }
72
+ deleteFactoryPending(tokenId, generation) {
73
+ if (this.factoryPending.get(tokenId)?.generation === generation) this.factoryPending.delete(tokenId);
74
+ }
51
75
  /**
52
76
  * Register a value provider for a token scoped to this context.
53
77
  */
@@ -111,6 +135,8 @@ var Scope = class Scope {
111
135
  this.cached.clear();
112
136
  this.pending.clear();
113
137
  this.valueProviders.clear();
138
+ this.factoryValues.clear();
139
+ this.factoryPending.clear();
114
140
  if (this.parent instanceof Scope) this.parent.activeChildren.delete(this);
115
141
  }
116
142
  if (errors.length > 0) {
package/dist/test.d.ts CHANGED
@@ -1,22 +1,38 @@
1
1
  import { Newable, Token, createToken } from "./lib/types.js";
2
+ import { ServiceScope } from "./lib/scope.js";
2
3
  import { ServiceIdentifier } from "./lib/service-identifiers.js";
3
- import { Container } from "./lib/container.js";
4
+ import { Container, FactoryFn } from "./lib/container.js";
4
5
  import { ProviderDefinitions } from "./lib/providers.js";
6
+ import { Scope } from "./scopes.js";
5
7
  import { MockOf } from "./lib/testing/mocking.js";
6
8
  import { vi } from "vitest";
7
9
 
8
10
  //#region src/test.d.ts
11
+ type FactoryOverrideSpec<T = unknown> = [token: Token<T>, factory: FactoryFn<T>, options?: {
12
+ lifecycle?: ServiceScope;
13
+ }];
14
+ type TestScopeHierarchy = Record<string, {
15
+ parent?: ServiceScope;
16
+ }>;
9
17
  interface OverrideSpec {
10
18
  /** Class constructor instance overrides */
11
19
  instances?: Array<[Newable<unknown>, unknown]>;
12
20
  /** Token value overrides */
13
21
  tokens?: Array<[Token<unknown>, unknown]>;
22
+ /**
23
+ * Token factory overrides. These are explicit token-keyed overrides and are
24
+ * not affected by autoMock, which only mocks class constructor dependencies.
25
+ * Token value overrides still take precedence for the same token.
26
+ */
27
+ factories?: FactoryOverrideSpec[];
14
28
  }
15
29
  interface CreateTestContainerOptions {
16
30
  overrides?: OverrideSpec;
17
31
  autoMock?: boolean;
18
32
  target?: Newable<unknown>;
19
33
  providers?: ProviderDefinitions | ProviderDefinitions[];
34
+ /** Custom scope hierarchy for tests, matching alloy({ scopes }) shape. */
35
+ scopes?: TestScopeHierarchy;
20
36
  }
21
37
  interface TestContainerHandle {
22
38
  container: Container;
@@ -26,6 +42,16 @@ interface TestContainerHandle {
26
42
  getToken<T>(token: Token<T>): T;
27
43
  /** Provide a token value into the container */
28
44
  provideToken?<T>(token: Token<T>, value: T): void;
45
+ /** Provide a token factory into the container */
46
+ provideFactory?<T>(token: Token<T>, factory: FactoryFn<T>, options?: {
47
+ lifecycle?: ServiceScope;
48
+ }): void;
49
+ /** Alias for provideFactory when replacing an existing test factory. */
50
+ overrideFactory?<T>(token: Token<T>, factory: FactoryFn<T>, options?: {
51
+ lifecycle?: ServiceScope;
52
+ }): void;
53
+ /** Create a scope under the test container. */
54
+ createScope?(scopeName: ServiceScope): Scope;
29
55
  /** Placeholder restore hook (future phases may implement overlay stacks). */
30
56
  restore(): void;
31
57
  /** Retrieve a single class mock (if autoMock enabled). */
@@ -39,12 +65,20 @@ interface TestContainerHandle {
39
65
  }
40
66
  /**
41
67
  * Create a test-focused container with manual overrides.
42
- * - Does not perform auto-mocking (Phase 2 will expand this).
68
+ * - Auto-mocking, when enabled, only mocks class constructor dependencies.
69
+ * - Token factories are explicit overrides and are applied after provider blocks.
43
70
  */
44
71
  declare function createTestContainer(opts?: CreateTestContainerOptions | OverrideSpec): TestContainerHandle & {
45
72
  getMock<T>(ctor: Newable<T>): MockOf<T> | undefined;
46
73
  getMocks<T extends readonly Newable<unknown>[]>(ctors: T): { [K in keyof T]: T[K] extends Newable<infer I> ? MockOf<I> | undefined : never };
47
74
  provideToken<T>(token: Token<T>, value: T): void;
75
+ provideFactory<T>(token: Token<T>, factory: FactoryFn<T>, options?: {
76
+ lifecycle?: ServiceScope;
77
+ }): void;
78
+ overrideFactory<T>(token: Token<T>, factory: FactoryFn<T>, options?: {
79
+ lifecycle?: ServiceScope;
80
+ }): void;
81
+ createScope(scopeName: ServiceScope): Scope;
48
82
  };
49
83
  //#endregion
50
- export { CreateTestContainerOptions, type MockOf, OverrideSpec, TestContainerHandle, createTestContainer, createToken };
84
+ export { CreateTestContainerOptions, FactoryOverrideSpec, type MockOf, OverrideSpec, TestContainerHandle, TestScopeHierarchy, createTestContainer, createToken };
package/dist/test.js CHANGED
@@ -3,18 +3,27 @@ import { Container } from "./lib/container.js";
3
3
  import { applyProviders } from "./lib/providers.js";
4
4
  import { restoreRegistry, snapshotRegistry } from "./lib/testing/registry.js";
5
5
  import { applyAutoMocks } from "./lib/testing/mocking.js";
6
+ import { createScope } from "./scopes.js";
6
7
  //#region src/test.ts
8
+ function normalizeScopeHierarchy(scopes) {
9
+ const hierarchy = {};
10
+ for (const [scopeName, config] of Object.entries(scopes)) hierarchy[scopeName] = config.parent ?? "singleton";
11
+ return hierarchy;
12
+ }
7
13
  /**
8
14
  * Create a test-focused container with manual overrides.
9
- * - Does not perform auto-mocking (Phase 2 will expand this).
15
+ * - Auto-mocking, when enabled, only mocks class constructor dependencies.
16
+ * - Token factories are explicit overrides and are applied after provider blocks.
10
17
  */
11
18
  function createTestContainer(opts) {
12
- const isLegacy = !!opts && !("autoMock" in opts) && !("target" in opts) && !("overrides" in opts);
19
+ const isLegacy = !!opts && !("autoMock" in opts) && !("target" in opts) && !("overrides" in opts) && !("providers" in opts) && !("scopes" in opts);
13
20
  const normalizedOpts = isLegacy ? { overrides: opts } : opts ?? {};
14
21
  const overrides = normalizedOpts.overrides ?? (isLegacy ? opts : void 0);
15
22
  const container = new Container();
16
23
  const snapshot = snapshotRegistry();
24
+ if (normalizedOpts.scopes) container._registerScopeHierarchy(normalizeScopeHierarchy(normalizedOpts.scopes));
17
25
  if (normalizedOpts.providers) applyProviders(container, normalizedOpts.providers);
26
+ for (const [tok, factory, options] of overrides?.factories ?? []) container.provideFactory(tok, factory, options);
18
27
  for (const [tok, value] of overrides?.tokens ?? []) container.provideValue(tok, value);
19
28
  const overriddenCtors = new Set(overrides?.instances?.map(([c]) => c) ?? []);
20
29
  for (const [ctor, instance] of overrides?.instances ?? []) container.overrideInstance(ctor, instance);
@@ -39,6 +48,15 @@ function createTestContainer(opts) {
39
48
  provideToken: (token, value) => {
40
49
  container.provideValue(token, value);
41
50
  },
51
+ provideFactory: (token, factory, options) => {
52
+ container.provideFactory(token, factory, options);
53
+ },
54
+ overrideFactory: (token, factory, options) => {
55
+ container.provideFactory(token, factory, options);
56
+ },
57
+ createScope: (scopeName) => {
58
+ return createScope(container, scopeName);
59
+ },
42
60
  getMock: (ctor) => {
43
61
  return mocks?.get(ctor) ?? void 0;
44
62
  },
@@ -1 +1 @@
1
- {"root":["../src/entry-points.test.ts","../src/fixture-subpaths.test.ts","../src/rollup.ts","../src/runtime.ts","../src/scopes.ts","../src/test.ts","../src/vite.ts","../src/lib/container.identifiers.test.ts","../src/lib/container.internals.test.ts","../src/lib/container.test.ts","../src/lib/container.testing-features.test.ts","../src/lib/container.ts","../src/lib/decorators.runtime.test.ts","../src/lib/decorators.test-d.ts","../src/lib/decorators.ts","../src/lib/dependency-error.ts","../src/lib/env-detection.test.ts","../src/lib/env-detection.ts","../src/lib/lazy-retry.test.ts","../src/lib/lazy.ts","../src/lib/providers.test.ts","../src/lib/providers.ts","../src/lib/scope.ts","../src/lib/scopes.test.ts","../src/lib/service-identifiers.test.ts","../src/lib/service-identifiers.ts","../src/lib/tokens.test.ts","../src/lib/types.ts","../src/lib/testing/mocking.test.ts","../src/lib/testing/mocking.ts","../src/lib/testing/registry.test.ts","../src/lib/testing/registry.ts","../src/plugins/core/codegen.test.ts","../src/plugins/core/codegen.ts","../src/plugins/core/container-loader.test.ts","../src/plugins/core/container-loader.ts","../src/plugins/core/decorators.helpers.test.ts","../src/plugins/core/decorators.ts","../src/plugins/core/discovery-runtime.ts","../src/plugins/core/discovery-store.test.ts","../src/plugins/core/discovery-store.ts","../src/plugins/core/identifier-resolver.test.ts","../src/plugins/core/identifier-resolver.ts","../src/plugins/core/lazy-utils.ts","../src/plugins/core/lazy.helpers.test.ts","../src/plugins/core/lazy.ts","../src/plugins/core/manifest-utils.ts","../src/plugins/core/manifest-utils.validation.test.ts","../src/plugins/core/scanner.test.ts","../src/plugins/core/scanner.ts","../src/plugins/core/scopes-validation.test.ts","../src/plugins/core/scopes-validation.ts","../src/plugins/core/types.ts","../src/plugins/core/utils.ts","../src/plugins/core/utils.walk.test.ts","../src/plugins/core/utils.write.test.ts","../src/plugins/core/visualization-utils.ts","../src/plugins/core/visualizer.test.ts","../src/plugins/core/visualizer.ts","../src/plugins/rollup-plugin/barrel-exports.test.ts","../src/plugins/rollup-plugin/barrel-exports.ts","../src/plugins/rollup-plugin/build-utils.test.ts","../src/plugins/rollup-plugin/build-utils.ts","../src/plugins/rollup-plugin/dependency-resolution.test.ts","../src/plugins/rollup-plugin/dependency-resolution.ts","../src/plugins/rollup-plugin/index.test.ts","../src/plugins/rollup-plugin/index.ts","../src/plugins/rollup-plugin/manifest-deps.test.ts","../src/plugins/rollup-plugin/manifest-deps.ts","../src/plugins/rollup-plugin/package-exports.test.ts","../src/plugins/rollup-plugin/package-exports.ts","../src/plugins/rollup-plugin/types.ts","../src/plugins/vite-plugin/duplicate-registrations.test.ts","../src/plugins/vite-plugin/index.ts","../src/plugins/vite-plugin/lazy-services.test.ts","../src/plugins/vite-plugin/lifecycle-and-hmr.test.ts","../src/plugins/vite-plugin/module-generation.test.ts","../src/plugins/vite-plugin/module-invalidation.ts","../src/plugins/vite-plugin/test-utils.ts","../src/plugins/vite-plugin/transform-guards.test.ts","../src/plugins/vite-plugin/visualization-option.test.ts"],"version":"6.0.3"}
1
+ {"root":["../src/entry-points.test.ts","../src/fixture-subpaths.test.ts","../src/rollup.ts","../src/runtime.ts","../src/scopes.ts","../src/test.test.ts","../src/test.ts","../src/vite.ts","../src/lib/container.factories-scoped.test.ts","../src/lib/container.factories.test.ts","../src/lib/container.identifiers.test.ts","../src/lib/container.internals.test.ts","../src/lib/container.test.ts","../src/lib/container.testing-features.test.ts","../src/lib/container.ts","../src/lib/decorators.runtime.test.ts","../src/lib/decorators.test-d.ts","../src/lib/decorators.ts","../src/lib/dependency-error.ts","../src/lib/env-detection.test.ts","../src/lib/env-detection.ts","../src/lib/lazy-retry.test.ts","../src/lib/lazy.ts","../src/lib/providers.test.ts","../src/lib/providers.ts","../src/lib/scope.ts","../src/lib/scopes.test.ts","../src/lib/service-identifiers.test.ts","../src/lib/service-identifiers.ts","../src/lib/tokens.test.ts","../src/lib/types.ts","../src/lib/testing/mocking.test.ts","../src/lib/testing/mocking.ts","../src/lib/testing/registry.test.ts","../src/lib/testing/registry.ts","../src/plugins/core/codegen.test.ts","../src/plugins/core/codegen.ts","../src/plugins/core/container-loader.test.ts","../src/plugins/core/container-loader.ts","../src/plugins/core/decorators.helpers.test.ts","../src/plugins/core/decorators.ts","../src/plugins/core/discovery-runtime.ts","../src/plugins/core/discovery-store.test.ts","../src/plugins/core/discovery-store.ts","../src/plugins/core/identifier-resolver.test.ts","../src/plugins/core/identifier-resolver.ts","../src/plugins/core/lazy-utils.ts","../src/plugins/core/lazy.helpers.test.ts","../src/plugins/core/lazy.ts","../src/plugins/core/manifest-utils.ts","../src/plugins/core/manifest-utils.validation.test.ts","../src/plugins/core/scanner.test.ts","../src/plugins/core/scanner.ts","../src/plugins/core/scopes-validation.test.ts","../src/plugins/core/scopes-validation.ts","../src/plugins/core/types.ts","../src/plugins/core/utils.ts","../src/plugins/core/utils.walk.test.ts","../src/plugins/core/utils.write.test.ts","../src/plugins/core/visualization-utils.ts","../src/plugins/core/visualizer.test.ts","../src/plugins/core/visualizer.ts","../src/plugins/rollup-plugin/barrel-exports.test.ts","../src/plugins/rollup-plugin/barrel-exports.ts","../src/plugins/rollup-plugin/build-utils.test.ts","../src/plugins/rollup-plugin/build-utils.ts","../src/plugins/rollup-plugin/dependency-resolution.test.ts","../src/plugins/rollup-plugin/dependency-resolution.ts","../src/plugins/rollup-plugin/index.test.ts","../src/plugins/rollup-plugin/index.ts","../src/plugins/rollup-plugin/manifest-deps.test.ts","../src/plugins/rollup-plugin/manifest-deps.ts","../src/plugins/rollup-plugin/package-exports.test.ts","../src/plugins/rollup-plugin/package-exports.ts","../src/plugins/rollup-plugin/types.ts","../src/plugins/vite-plugin/duplicate-registrations.test.ts","../src/plugins/vite-plugin/index.ts","../src/plugins/vite-plugin/lazy-services.test.ts","../src/plugins/vite-plugin/lifecycle-and-hmr.test.ts","../src/plugins/vite-plugin/module-generation.test.ts","../src/plugins/vite-plugin/module-invalidation.ts","../src/plugins/vite-plugin/test-utils.ts","../src/plugins/vite-plugin/transform-guards.test.ts","../src/plugins/vite-plugin/visualization-option.test.ts"],"version":"6.0.3"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "alloy-di",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "description": "A build-time dependency injection plugin for Vite",
5
5
  "keywords": [
6
6
  "dependency-injection",