sandly
Version:
**⚠️ This project is under heavy development and APIs may change.**
1,264 lines (1,255 loc) • 42.8 kB
JavaScript
import { AsyncLocalStorage } from "node:async_hooks";
//#region src/utils/object.ts
function hasKey(obj, key) {
return obj !== void 0 && obj !== null && (typeof obj === "object" || typeof obj === "function") && key in obj;
}
function getKey(obj, ...keys) {
let current = obj;
for (const key of keys) {
if (!hasKey(current, key)) return void 0;
current = current[key];
}
return current;
}
//#endregion
//#region src/tag.ts
/**
* Symbol used to identify tagged types within the dependency injection system.
* This symbol is used as a property key to attach metadata to both value tags and service tags.
*
* Note: We can't use a symbol here becuase it produced the following TS error:
* error TS4020: 'extends' clause of exported class 'NotificationService' has or is using private name 'TagIdKey'.
*
* @internal
*/
const ValueTagIdKey = "sandly/ValueTagIdKey";
const ServiceTagIdKey = "sandly/ServiceTagIdKey";
/**
* Internal string used to identify the type of a tagged type within the dependency injection system.
* This string is used as a property key to attach metadata to both value tags and service tags.
* It is used to carry the type of the tagged type and should not be used directly.
* @internal
*/
const TagTypeKey = "sandly/TagTypeKey";
/**
* Utility object containing factory functions for creating dependency tags.
*
* The Tag object provides the primary API for creating both value tags and service tags
* used throughout the dependency injection system. It's the main entry point for
* defining dependencies in a type-safe way.
*/
const Tag = {
of: (id) => {
return () => ({
[ValueTagIdKey]: id,
[TagTypeKey]: void 0
});
},
for: () => {
return {
[ValueTagIdKey]: Symbol(),
[TagTypeKey]: void 0
};
},
Service: (id) => {
class Tagged {
static [ServiceTagIdKey] = id;
[ServiceTagIdKey] = id;
}
return Tagged;
},
id: (tag) => {
return typeof tag === "function" ? tag[ServiceTagIdKey] : tag[ValueTagIdKey];
},
isTag: (tag) => {
return typeof tag === "function" ? getKey(tag, ServiceTagIdKey) !== void 0 : getKey(tag, ValueTagIdKey) !== void 0;
}
};
/**
* String used to store the original ValueTag in Inject<T> types.
* This prevents property name collisions while allowing type-level extraction.
*/
const InjectSource = "sandly/InjectSource";
//#endregion
//#region src/errors.ts
var BaseError = class BaseError extends Error {
detail;
constructor(message, { cause, detail } = {}) {
super(message, { cause });
this.name = this.constructor.name;
this.detail = detail;
if (cause instanceof Error && cause.stack !== void 0) this.stack = `${this.stack}\nCaused by: ${cause.stack}`;
}
static ensure(error) {
return error instanceof BaseError ? error : new BaseError("An unknown error occurred", { cause: error });
}
dump() {
const cause = this.cause instanceof BaseError ? this.cause.dump().error : this.cause;
const result = {
name: this.name,
message: this.message,
cause,
detail: this.detail ?? {}
};
return {
name: this.name,
message: result.message,
stack: this.stack,
error: result
};
}
dumps() {
return JSON.stringify(this.dump());
}
};
/**
* Base error class for all dependency container related errors.
*
* This extends the framework's BaseError to provide consistent error handling
* and structured error information across the dependency injection system.
*
* @example Catching DI errors
* ```typescript
* try {
* await container.resolve(SomeService);
* } catch (error) {
* if (error instanceof ContainerError) {
* console.error('DI Error:', error.message);
* console.error('Details:', error.detail);
* }
* }
* ```
*/
var ContainerError = class extends BaseError {};
/**
* Error thrown when attempting to register a dependency that has already been instantiated.
*
* This error occurs when calling `container.register()` for a tag that has already been instantiated.
* Registration must happen before any instantiation occurs, as cached instances would still be used
* by existing dependencies.
*/
var DependencyAlreadyInstantiatedError = class extends ContainerError {};
/**
* Error thrown when attempting to use a container that has been destroyed.
*
* This error occurs when calling `container.resolve()`, `container.register()`, or `container.destroy()`
* on a container that has already been destroyed. It indicates a programming error where the container
* is being used after it has been destroyed.
*/
var ContainerDestroyedError = class extends ContainerError {};
/**
* Error thrown when attempting to retrieve a dependency that hasn't been registered.
*
* This error occurs when calling `container.resolve(Tag)` for a tag that was never
* registered via `container.register()`. It indicates a programming error where
* the dependency setup is incomplete.
*
* @example
* ```typescript
* const container = Container.empty(); // Empty container
*
* try {
* await c.resolve(UnregisteredService); // This will throw
* } catch (error) {
* if (error instanceof UnknownDependencyError) {
* console.error('Missing dependency:', error.message);
* }
* }
* ```
*/
var UnknownDependencyError = class extends ContainerError {
/**
* @internal
* Creates an UnknownDependencyError for the given tag.
*
* @param tag - The dependency tag that wasn't found
*/
constructor(tag) {
super(`No factory registered for dependency ${String(Tag.id(tag))}`);
}
};
/**
* Error thrown when a circular dependency is detected during dependency resolution.
*
* This occurs when service A depends on service B, which depends on service A (directly
* or through a chain of dependencies). The error includes the full dependency chain
* to help identify the circular reference.
*
* @example Circular dependency scenario
* ```typescript
* class ServiceA extends Tag.Service('ServiceA') {}
* class ServiceB extends Tag.Service('ServiceB') {}
*
* const container = Container.empty()
* .register(ServiceA, async (ctx) =>
* new ServiceA(await ctx.resolve(ServiceB)) // Depends on B
* )
* .register(ServiceB, async (ctx) =>
* new ServiceB(await ctx.resolve(ServiceA)) // Depends on A - CIRCULAR!
* );
*
* try {
* await c.resolve(ServiceA);
* } catch (error) {
* if (error instanceof CircularDependencyError) {
* console.error('Circular dependency:', error.message);
* // Output: "Circular dependency detected for ServiceA: ServiceA -> ServiceB -> ServiceA"
* }
* }
* ```
*/
var CircularDependencyError = class extends ContainerError {
/**
* @internal
* Creates a CircularDependencyError with the dependency chain information.
*
* @param tag - The tag where the circular dependency was detected
* @param dependencyChain - The chain of dependencies that led to the circular reference
*/
constructor(tag, dependencyChain) {
const chain = dependencyChain.map((t) => Tag.id(t)).join(" -> ");
super(`Circular dependency detected for ${String(Tag.id(tag))}: ${chain} -> ${String(Tag.id(tag))}`, { detail: {
tag: Tag.id(tag),
dependencyChain: dependencyChain.map((t) => Tag.id(t))
} });
}
};
/**
* Error thrown when a dependency factory function throws an error during instantiation.
*
* This wraps the original error with additional context about which dependency
* failed to be created. The original error is preserved as the `cause` property.
*
* @example Factory throwing error
* ```typescript
* class DatabaseService extends Tag.Service('DatabaseService') {}
*
* const container = Container.empty().register(DatabaseService, () => {
* throw new Error('Database connection failed');
* });
*
* try {
* await c.resolve(DatabaseService);
* } catch (error) {
* if (error instanceof DependencyCreationError) {
* console.error('Failed to create:', error.message);
* console.error('Original error:', error.cause);
* }
* }
* ```
*/
var DependencyCreationError = class extends ContainerError {
/**
* @internal
* Creates a DependencyCreationError wrapping the original factory error.
*
* @param tag - The tag of the dependency that failed to be created
* @param error - The original error thrown by the factory function
*/
constructor(tag, error) {
super(`Error creating instance of ${String(Tag.id(tag))}`, {
cause: error,
detail: { tag: Tag.id(tag) }
});
}
};
/**
* Error thrown when one or more finalizers fail during container destruction.
*
* This error aggregates multiple finalizer failures that occurred during
* `container.destroy()`. Even if some finalizers fail, the container cleanup
* process continues and this error contains details of all failures.
*
* @example Handling finalization errors
* ```typescript
* try {
* await container.destroy();
* } catch (error) {
* if (error instanceof DependencyFinalizationError) {
* console.error('Some finalizers failed');
* console.error('Error details:', error.detail.errors);
* }
* }
* ```
*/
var DependencyFinalizationError = class extends ContainerError {
/**
* @internal
* Creates a DependencyFinalizationError aggregating multiple finalizer failures.
*
* @param errors - Array of errors thrown by individual finalizers
*/
constructor(errors) {
const lambdaErrors = errors.map((error) => BaseError.ensure(error));
super("Error destroying dependency container", {
cause: errors[0],
detail: { errors: lambdaErrors.map((error) => error.dump()) }
});
}
};
//#endregion
//#region src/container.ts
/**
* AsyncLocalStorage instance used to track the dependency resolution chain.
* This enables detection of circular dependencies during async dependency resolution.
* @internal
*/
const resolutionChain = new AsyncLocalStorage();
const ContainerTypeId = Symbol.for("sandly/Container");
/**
* A type-safe dependency injection container that manages service instantiation,
* caching, and lifecycle management with support for async dependencies and
* circular dependency detection.
*
* The container maintains complete type safety by tracking registered dependencies
* at the type level, ensuring that only registered dependencies can be retrieved
* and preventing runtime errors.
*
* @template TReg - Union type of all registered dependency tags in this container
*
* @example Basic usage with service tags
* ```typescript
* import { container, Tag } from 'sandly';
*
* class DatabaseService extends Tag.Service('DatabaseService') {
* query() { return 'data'; }
* }
*
* class UserService extends Tag.Service('UserService') {
* constructor(private db: DatabaseService) {}
* getUser() { return this.db.query(); }
* }
*
* const container = Container.empty()
* .register(DatabaseService, () => new DatabaseService())
* .register(UserService, async (ctx) =>
* new UserService(await ctx.resolve(DatabaseService))
* );
*
* const userService = await c.resolve(UserService);
* ```
*
* @example Usage with value tags
* ```typescript
* const ApiKeyTag = Tag.of('apiKey')<string>();
* const ConfigTag = Tag.of('config')<{ dbUrl: string }>();
*
* const container = Container.empty()
* .register(ApiKeyTag, () => process.env.API_KEY!)
* .register(ConfigTag, () => ({ dbUrl: 'postgresql://localhost:5432' }));
*
* const apiKey = await c.resolve(ApiKeyTag);
* const config = await c.resolve(ConfigTag);
* ```
*
* @example With finalizers for cleanup
* ```typescript
* class DatabaseConnection extends Tag.Service('DatabaseConnection') {
* async connect() { return; }
* async disconnect() { return; }
* }
*
* const container = Container.empty().register(
* DatabaseConnection,
* async () => {
* const conn = new DatabaseConnection();
* await conn.connect();
* return conn;
* },
* async (conn) => conn.disconnect() // Finalizer for cleanup
* );
*
* // Later...
* await c.destroy(); // Calls all finalizers
* ```
*/
var Container = class Container {
[ContainerTypeId];
constructor() {}
/**
* Cache of instantiated dependencies as promises.
* Ensures singleton behavior and supports concurrent access.
* @internal
*/
cache = /* @__PURE__ */ new Map();
/**
* Factory functions for creating dependency instances.
* @internal
*/
factories = /* @__PURE__ */ new Map();
/**
* Finalizer functions for cleaning up dependencies when the container is destroyed.
* @internal
*/
finalizers = /* @__PURE__ */ new Map();
/**
* Flag indicating whether this container has been destroyed.
* @internal
*/
isDestroyed = false;
/**
* Creates a new empty container instance.
* @returns A new empty Container instance with no registered dependencies.
*/
static empty() {
return new Container();
}
/**
* Registers a dependency in the container with a factory function and optional finalizer.
*
* The factory function receives the current container instance and must return the
* service instance (or a Promise of it). The container tracks the registration at
* the type level, ensuring type safety for subsequent `.resolve()` calls.
*
* If a dependency is already registered, this method will override it unless the
* dependency has already been instantiated, in which case it will throw an error.
*
* @template T - The dependency tag being registered
* @param tag - The dependency tag (class or value tag)
* @param factory - Function that creates the service instance, receives container for dependency injection
* @param finalizer - Optional cleanup function called when container is destroyed
* @returns A new container instance with the dependency registered
* @throws {ContainerDestroyedError} If the container has been destroyed
* @throws {Error} If the dependency has already been instantiated
*
* @example Registering a simple service
* ```typescript
* class LoggerService extends Tag.Service('LoggerService') {
* log(message: string) { console.log(message); }
* }
*
* const container = Container.empty().register(
* LoggerService,
* () => new LoggerService()
* );
* ```
*
* @example Registering with dependencies
* ```typescript
* class UserService extends Tag.Service('UserService') {
* constructor(private db: DatabaseService, private logger: LoggerService) {}
* }
*
* const container = Container.empty()
* .register(DatabaseService, () => new DatabaseService())
* .register(LoggerService, () => new LoggerService())
* .register(UserService, async (ctx) =>
* new UserService(
* await ctx.resolve(DatabaseService),
* await ctx.resolve(LoggerService)
* )
* );
* ```
*
* @example Overriding a dependency
* ```typescript
* const container = Container.empty()
* .register(DatabaseService, () => new DatabaseService())
* .register(DatabaseService, () => new MockDatabaseService()); // Overrides the previous registration
* ```
*
* @example Using value tags
* ```typescript
* const ConfigTag = Tag.of('config')<{ apiUrl: string }>();
*
* const container = Container.empty().register(
* ConfigTag,
* () => ({ apiUrl: 'https://api.example.com' })
* );
* ```
*
* @example With finalizer for cleanup
* ```typescript
* class DatabaseConnection extends Tag.Service('DatabaseConnection') {
* async connect() { return; }
* async close() { return; }
* }
*
* const container = Container.empty().register(
* DatabaseConnection,
* async () => {
* const conn = new DatabaseConnection();
* await conn.connect();
* return conn;
* },
* (conn) => conn.close() // Called during container.destroy()
* );
* ```
*/
register(tag, spec) {
if (this.isDestroyed) throw new ContainerDestroyedError("Cannot register dependencies on a destroyed container");
if (this.has(tag) && this.exists(tag)) throw new DependencyAlreadyInstantiatedError(`Cannot register dependency ${String(Tag.id(tag))} - it has already been instantiated. Registration must happen before any instantiation occurs, as cached instances would still be used by existing dependencies.`);
if (typeof spec === "function") {
this.factories.set(tag, spec);
this.finalizers.delete(tag);
} else {
this.factories.set(tag, spec.factory);
this.finalizers.set(tag, spec.finalizer);
}
return this;
}
/**
* Checks if a dependency has been registered in the container.
*
* This returns `true` if the dependency has been registered via `.register()`,
* regardless of whether it has been instantiated yet.
*
* @param tag - The dependency tag to check
* @returns `true` if the dependency has been registered, `false` otherwise
*
* @example
* ```typescript
* const container = Container.empty().register(DatabaseService, () => new DatabaseService());
* console.log(c.has(DatabaseService)); // true
* ```
*/
has(tag) {
return this.factories.has(tag);
}
/**
* Checks if a dependency has been instantiated (cached) in the container.
*
* @param tag - The dependency tag to check
* @returns true if the dependency has been instantiated, false otherwise
*/
exists(tag) {
return this.cache.has(tag);
}
/**
* Retrieves a dependency instance from the container, creating it if necessary.
*
* This method ensures singleton behavior - each dependency is created only once
* and cached for subsequent calls. The method is async-safe and handles concurrent
* requests for the same dependency correctly.
*
* The method performs circular dependency detection using AsyncLocalStorage to track
* the resolution chain across async boundaries.
*
* @template T - The dependency tag type (must be registered in this container)
* @param tag - The dependency tag to retrieve
* @returns Promise resolving to the service instance
* @throws {UnknownDependencyError} If the dependency is not registered
* @throws {CircularDependencyError} If a circular dependency is detected
* @throws {DependencyCreationError} If the factory function throws an error
*
* @example Basic usage
* ```typescript
* const container = Container.empty()
* .register(DatabaseService, () => new DatabaseService());
*
* const db = await c.resolve(DatabaseService);
* db.query('SELECT * FROM users');
* ```
*
* @example Concurrent access (singleton behavior)
* ```typescript
* // All three calls will receive the same instance
* const [db1, db2, db3] = await Promise.all([
* c.resolve(DatabaseService),
* c.resolve(DatabaseService),
* c.resolve(DatabaseService)
* ]);
*
* console.log(db1 === db2 === db3); // true
* ```
*
* @example Dependency injection in factories
* ```typescript
* const container = Container.empty()
* .register(DatabaseService, () => new DatabaseService())
* .register(UserService, async (ctx) => {
* const db = await ctx.resolve(DatabaseService);
* return new UserService(db);
* });
*
* const userService = await c.resolve(UserService);
* ```
*/
async resolve(tag) {
if (this.isDestroyed) throw new ContainerDestroyedError("Cannot resolve dependencies from a destroyed container");
const cached = this.cache.get(tag);
if (cached !== void 0) return cached;
const currentChain = resolutionChain.getStore() ?? [];
if (currentChain.includes(tag)) throw new CircularDependencyError(tag, currentChain);
const factory = this.factories.get(tag);
if (factory === void 0) throw new UnknownDependencyError(tag);
const instancePromise = resolutionChain.run([...currentChain, tag], async () => {
try {
const instance = await factory(this);
return instance;
} catch (error) {
throw new DependencyCreationError(tag, error);
}
}).catch((error) => {
this.cache.delete(tag);
throw error;
});
this.cache.set(tag, instancePromise);
return instancePromise;
}
/**
* Resolves multiple dependencies concurrently using Promise.all.
*
* This method takes a variable number of dependency tags and resolves all of them concurrently,
* returning a tuple with the resolved instances in the same order as the input tags.
* The method maintains all the same guarantees as the individual resolve method:
* singleton behavior, circular dependency detection, and proper error handling.
*
* @template T - The tuple type of dependency tags to resolve
* @param tags - Variable number of dependency tags to resolve
* @returns Promise resolving to a tuple of service instances in the same order
* @throws {ContainerDestroyedError} If the container has been destroyed
* @throws {UnknownDependencyError} If any dependency is not registered
* @throws {CircularDependencyError} If a circular dependency is detected
* @throws {DependencyCreationError} If any factory function throws an error
*
* @example Basic usage
* ```typescript
* const container = Container.empty()
* .register(DatabaseService, () => new DatabaseService())
* .register(LoggerService, () => new LoggerService());
*
* const [db, logger] = await c.resolveAll(DatabaseService, LoggerService);
* ```
*
* @example Mixed tag types
* ```typescript
* const ApiKeyTag = Tag.of('apiKey')<string>();
* const container = Container.empty()
* .register(ApiKeyTag, () => 'secret-key')
* .register(UserService, () => new UserService());
*
* const [apiKey, userService] = await c.resolveAll(ApiKeyTag, UserService);
* ```
*
* @example Empty array
* ```typescript
* const results = await c.resolveAll(); // Returns empty array
* ```
*/
async resolveAll(...tags) {
if (this.isDestroyed) throw new ContainerDestroyedError("Cannot resolve dependencies from a destroyed container");
const promises = tags.map((tag) => this.resolve(tag));
const results = await Promise.all(promises);
return results;
}
/**
* Copies all registrations from this container to a target container.
*
* @internal
* @param target - The container to copy registrations to
* @throws {ContainerDestroyedError} If this container has been destroyed
*/
copyTo(target) {
if (this.isDestroyed) throw new ContainerDestroyedError("Cannot copy registrations from a destroyed container");
for (const [tag, factory] of this.factories) {
const finalizer = this.finalizers.get(tag);
if (finalizer) target.register(tag, {
factory,
finalizer
});
else target.register(tag, factory);
}
}
/**
* Creates a new container by merging this container's registrations with another container.
*
* This method creates a new container that contains all registrations from both containers.
* If there are conflicts (same dependency registered in both containers), this
* container's registration will take precedence.
*
* **Important**: Only the registrations are copied, not any cached instances.
* The new container starts with an empty instance cache.
*
* @param other - The container to merge with
* @returns A new container with combined registrations
* @throws {ContainerDestroyedError} If this container has been destroyed
*
* @example Merging containers
* ```typescript
* const container1 = Container.empty()
* .register(DatabaseService, () => new DatabaseService());
*
* const container2 = Container.empty()
* .register(UserService, () => new UserService());
*
* const merged = container1.merge(container2);
* // merged has both DatabaseService and UserService
* ```
*/
merge(other) {
if (this.isDestroyed) throw new ContainerDestroyedError("Cannot merge from a destroyed container");
const merged = new Container();
other.copyTo(merged);
this.copyTo(merged);
return merged;
}
/**
* Destroys all instantiated dependencies by calling their finalizers and makes the container unusable.
*
* **Important: After calling destroy(), the container becomes permanently unusable.**
* Any subsequent calls to register(), get(), or destroy() will throw a ContainerError.
* This ensures proper cleanup and prevents runtime errors from accessing destroyed resources.
*
* All finalizers for instantiated dependencies are called concurrently using Promise.allSettled()
* for maximum cleanup performance.
* If any finalizers fail, all errors are collected and a DependencyContainerFinalizationError
* is thrown containing details of all failures.
*
* **Finalizer Concurrency:** Finalizers run concurrently, so there are no ordering guarantees.
* Services should be designed to handle cleanup gracefully regardless of the order in which their
* dependencies are cleaned up.
*
* @returns Promise that resolves when all cleanup is complete
* @throws {DependencyFinalizationError} If any finalizers fail during cleanup
*
* @example Basic cleanup
* ```typescript
* const container = Container.empty()
* .register(DatabaseConnection,
* async () => {
* const conn = new DatabaseConnection();
* await conn.connect();
* return conn;
* },
* (conn) => conn.disconnect() // Finalizer
* );
*
* const db = await c.resolve(DatabaseConnection);
* await c.destroy(); // Calls conn.disconnect(), container becomes unusable
*
* // This will throw an error
* try {
* await c.resolve(DatabaseConnection);
* } catch (error) {
* console.log(error.message); // "Cannot resolve dependencies from a destroyed container"
* }
* ```
*
* @example Application shutdown
* ```typescript
* const appContainer Container.empty
* .register(DatabaseService, () => new DatabaseService())
* .register(HTTPServer, async (ctx) => new HTTPServer(await ctx.resolve(DatabaseService)));
*
* // During application shutdown
* process.on('SIGTERM', async () => {
* try {
* await appContainer.destroy(); // Clean shutdown of all services
* } catch (error) {
* console.error('Error during shutdown:', error);
* }
* process.exit(0);
* });
* ```
*
* @example Handling cleanup errors
* ```typescript
* try {
* await container.destroy();
* } catch (error) {
* if (error instanceof DependencyContainerFinalizationError) {
* console.error('Some dependencies failed to clean up:', error.detail.errors);
* }
* }
* // Container is destroyed regardless of finalizer errors
* ```
*/
async destroy() {
if (this.isDestroyed) return;
try {
const promises = Array.from(this.finalizers.entries()).filter(([tag]) => this.cache.has(tag)).map(async ([tag, finalizer]) => {
const dep = await this.cache.get(tag);
return finalizer(dep);
});
const results = await Promise.allSettled(promises);
const failures = results.filter((result) => result.status === "rejected");
if (failures.length > 0) throw new DependencyFinalizationError(failures.map((result) => result.reason));
} finally {
this.isDestroyed = true;
this.cache.clear();
}
}
};
//#endregion
//#region src/layer.ts
/**
* The type ID for the Layer interface.
*/
const LayerTypeId = Symbol.for("sandly/Layer");
/**
* Creates a new dependency layer that encapsulates a set of dependency registrations.
* Layers are the primary building blocks for organizing and composing dependency injection setups.
*
* @template TRequires - The union of dependency tags this layer requires from other layers or external setup
* @template TProvides - The union of dependency tags this layer registers/provides
*
* @param register - Function that performs the dependency registrations. Receives a container.
* @returns The layer instance.
*
* @example Simple layer
* ```typescript
* import { layer, Tag } from 'sandly';
*
* class DatabaseService extends Tag.Service('DatabaseService') {
* constructor(private url: string = 'sqlite://memory') {}
* query() { return 'data'; }
* }
*
* // Layer that provides DatabaseService, requires nothing
* const databaseLayer = layer<never, typeof DatabaseService>((container) =>
* container.register(DatabaseService, () => new DatabaseService())
* );
*
* // Usage
* const dbLayerInstance = databaseLayer;
* ```
*
* @example Complex application layer structure
* ```typescript
* // Configuration layer
* const configLayer = layer<never, typeof ConfigTag>((container) =>
* container.register(ConfigTag, () => loadConfig())
* );
*
* // Infrastructure layer (requires config)
* const infraLayer = layer<typeof ConfigTag, typeof DatabaseService | typeof CacheService>(
* (container) =>
* container
* .register(DatabaseService, async (ctx) => new DatabaseService(await ctx.resolve(ConfigTag)))
* .register(CacheService, async (ctx) => new CacheService(await ctx.resolve(ConfigTag)))
* );
*
* // Service layer (requires infrastructure)
* const serviceLayer = layer<typeof DatabaseService | typeof CacheService, typeof UserService>(
* (container) =>
* container.register(UserService, async (ctx) =>
* new UserService(await ctx.resolve(DatabaseService), await ctx.resolve(CacheService))
* )
* );
*
* // Compose the complete application
* const appLayer = serviceLayer.provide(infraLayer).provide(configLayer);
* ```
*/
function layer(register) {
const layerImpl = {
register: (container) => register(container),
provide(dependency) {
return createProvidedLayer(dependency, layerImpl);
},
provideMerge(dependency) {
return createComposedLayer(dependency, layerImpl);
},
merge(other) {
return createMergedLayer(layerImpl, other);
}
};
return layerImpl;
}
/**
* Internal function to create a provided layer from two layers.
* This implements the `.provide()` method logic - only exposes target layer's provisions.
*
* @internal
*/
function createProvidedLayer(dependency, target) {
return createComposedLayer(dependency, target);
}
/**
* Internal function to create a composed layer from two layers.
* This implements the `.provideMerge()` method logic - exposes both layers' provisions.
*
* @internal
*/
function createComposedLayer(dependency, target) {
return layer((container) => {
const containerWithDependency = dependency.register(container);
return target.register(containerWithDependency);
});
}
/**
* Internal function to create a merged layer from two layers.
* This implements the `.merge()` method logic.
*
* @internal
*/
function createMergedLayer(layer1, layer2) {
return layer((container) => {
const container1 = layer1.register(container);
const container2 = layer2.register(container1);
return container2;
});
}
/**
* Utility object containing helper functions for working with layers.
*/
const Layer = {
empty() {
return layer((container) => container);
},
mergeAll(...layers) {
return layers.reduce((acc, layer$1) => acc.merge(layer$1));
},
merge(layer1, layer2) {
return layer1.merge(layer2);
}
};
//#endregion
//#region src/scoped-container.ts
var ScopedContainer = class ScopedContainer extends Container {
scope;
parent;
children = [];
constructor(parent, scope) {
super();
this.parent = parent;
this.scope = scope;
}
/**
* Creates a new empty scoped container instance.
* @param scope - The scope identifier for this container
* @returns A new empty ScopedContainer instance with no registered dependencies
*/
static empty(scope) {
return new ScopedContainer(null, scope);
}
/**
* Registers a dependency in the scoped container.
*
* Overrides the base implementation to return ScopedContainer type
* for proper method chaining support.
*/
register(tag, spec) {
super.register(tag, spec);
return this;
}
/**
* Checks if a dependency has been registered in this scope or any parent scope.
*
* This method checks the current scope first, then walks up the parent chain.
* Returns true if the dependency has been registered somewhere in the scope hierarchy.
*/
has(tag) {
if (super.has(tag)) return true;
return this.parent?.has(tag) ?? false;
}
/**
* Checks if a dependency has been instantiated in this scope or any parent scope.
*
* This method checks the current scope first, then walks up the parent chain.
* Returns true if the dependency has been instantiated somewhere in the scope hierarchy.
*/
exists(tag) {
if (super.exists(tag)) return true;
return this.parent?.exists(tag) ?? false;
}
/**
* Retrieves a dependency instance, resolving from the current scope or parent scopes.
*
* Resolution strategy:
* 1. Check cache in current scope
* 2. Check if factory exists in current scope - if so, create instance here
* 3. Otherwise, delegate to parent scope
* 4. If no parent or parent doesn't have it, throw UnknownDependencyError
*/
async resolve(tag) {
if (this.factories.has(tag)) return super.resolve(tag);
if (this.parent !== null) return this.parent.resolve(tag);
throw new UnknownDependencyError(tag);
}
/**
* Destroys this scoped container and its children, preserving the container structure for reuse.
*
* This method ensures proper cleanup order while maintaining reusability:
* 1. Destroys all child scopes first (they may depend on parent scope dependencies)
* 2. Then calls finalizers for dependencies created in this scope
* 3. Clears only instance caches - preserves factories, finalizers, and child structure
*
* Child destruction happens first to ensure dependencies don't get cleaned up
* before their dependents.
*/
async destroy() {
if (this.isDestroyed) return;
const allFailures = [];
const childDestroyPromises = this.children.map((weakRef) => weakRef.deref()).filter((child) => child !== void 0).map((child) => child.destroy());
const childResults = await Promise.allSettled(childDestroyPromises);
const childFailures = childResults.filter((result) => result.status === "rejected").map((result) => result.reason);
allFailures.push(...childFailures);
try {
await super.destroy();
} catch (error) {
allFailures.push(error);
} finally {
this.parent = null;
}
if (allFailures.length > 0) throw new DependencyFinalizationError(allFailures);
}
/**
* Creates a new scoped container by merging this container's registrations with another container.
*
* This method overrides the base Container.merge to return a ScopedContainer instead of a regular Container.
* The resulting scoped container contains all registrations from both containers and becomes a root scope
* (no parent) with the scope name from this container.
*
* @param other - The container to merge with
* @returns A new ScopedContainer with combined registrations
* @throws {ContainerDestroyedError} If this container has been destroyed
*/
merge(other) {
if (this.isDestroyed) throw new ContainerDestroyedError("Cannot merge from a destroyed container");
const merged = new ScopedContainer(null, this.scope);
other.copyTo(merged);
this.copyTo(merged);
return merged;
}
/**
* Creates a child scoped container.
*
* Child containers inherit access to parent dependencies but maintain
* their own scope for new registrations and instance caching.
*/
child(scope) {
if (this.isDestroyed) throw new ContainerDestroyedError("Cannot create child containers from a destroyed container");
const child = new ScopedContainer(this, scope);
this.children.push(new WeakRef(child));
return child;
}
};
/**
* Converts a regular container into a scoped container, copying all registrations.
*
* This function creates a new ScopedContainer instance and copies all factory functions
* and finalizers from the source container. The resulting scoped container becomes a root
* scope (no parent) with all the same dependency registrations.
*
* **Important**: Only the registrations are copied, not any cached instances.
* The new scoped container starts with an empty instance cache.
*
* @param container - The container to convert to a scoped container
* @param scope - A string or symbol identifier for this scope (used for debugging)
* @returns A new ScopedContainer instance with all registrations copied from the source container
* @throws {ContainerDestroyedError} If the source container has been destroyed
*
* @example Converting a regular container to scoped
* ```typescript
* import { container, scoped } from 'sandly';
*
* const appContainer = Container.empty()
* .register(DatabaseService, () => new DatabaseService())
* .register(ConfigService, () => new ConfigService());
*
* const scopedAppContainer = scoped(appContainer, 'app');
*
* // Create child scopes
* const requestContainer = scopedAppContainer.child('request');
* ```
*
* @example Copying complex registrations
* ```typescript
* const baseContainer = Container.empty()
* .register(DatabaseService, () => new DatabaseService())
* .register(UserService, {
* factory: async (ctx) => new UserService(await ctx.resolve(DatabaseService)),
* finalizer: (service) => service.cleanup()
* });
*
* const scopedContainer = scoped(baseContainer, 'app');
* // scopedContainer now has all the same registrations with finalizers preserved
* ```
*/
function scoped(container, scope) {
const emptyScoped = ScopedContainer.empty(scope);
return emptyScoped.merge(container);
}
//#endregion
//#region src/service.ts
/**
* Creates a service layer from any tag type (ServiceTag or ValueTag) with optional parameters.
*
* For ServiceTag services:
* - Dependencies are automatically inferred from constructor parameters
* - The factory function must handle dependency injection by resolving dependencies from the container
*
* For ValueTag services:
* - No constructor dependencies are needed since they don't have constructors
*
* @template T - The tag representing the service (ServiceTag or ValueTag)
* @param tag - The tag (ServiceTag or ValueTag)
* @param factory - Factory function for service instantiation with container
* @returns The service layer
*
* @example Simple service without dependencies
* ```typescript
* class LoggerService extends Tag.Service('LoggerService') {
* log(message: string) { console.log(message); }
* }
*
* const loggerService = service(LoggerService, () => new LoggerService());
* ```
*
* @example Service with dependencies
* ```typescript
* class DatabaseService extends Tag.Service('DatabaseService') {
* query() { return []; }
* }
*
* class UserService extends Tag.Service('UserService') {
* constructor(private db: DatabaseService) {
* super();
* }
*
* getUsers() { return this.db.query(); }
* }
*
* const userService = service(UserService, async (ctx) =>
* new UserService(await ctx.resolve(DatabaseService))
* );
* ```
*/
function service(tag, spec) {
return layer((container) => {
return container.register(tag, spec);
});
}
/**
* Creates a service layer with automatic dependency injection by inferring constructor parameters.
*
* This is a convenience function that automatically resolves constructor dependencies and passes
* both DI-managed dependencies and static values to the service constructor in the correct order.
* It eliminates the need to manually write factory functions for services with constructor dependencies.
*
* @template T - The ServiceTag representing the service class
* @param tag - The service tag (must be a ServiceTag, not a ValueTag)
* @param deps - Tuple of constructor parameters in order - mix of dependency tags and static values
* @param finalizer - Optional cleanup function called when the container is destroyed
* @returns A service layer that automatically handles dependency injection
*
* @example Simple service with dependencies
* ```typescript
* class DatabaseService extends Tag.Service('DatabaseService') {
* constructor(private url: string) {
* super();
* }
* connect() { return `Connected to ${this.url}`; }
* }
*
* class UserService extends Tag.Service('UserService') {
* constructor(private db: DatabaseService, private timeout: number) {
* super();
* }
* getUsers() { return this.db.query('SELECT * FROM users'); }
* }
*
* // Automatically inject DatabaseService and pass static timeout value
* const userService = autoService(UserService, [DatabaseService, 5000]);
* ```
*
* @example Mixed dependencies and static values
* ```typescript
* class NotificationService extends Tag.Service('NotificationService') {
* constructor(
* private logger: LoggerService,
* private apiKey: string,
* private retries: number,
* private cache: CacheService
* ) {
* super();
* }
* }
*
* // Mix of DI tags and static values in constructor order
* const notificationService = autoService(NotificationService, [
* LoggerService, // Will be resolved from container
* 'secret-api-key', // Static string value
* 3, // Static number value
* CacheService // Will be resolved from container
* ]);
* ```
*
* @example Compared to manual service creation
* ```typescript
* // Manual approach (more verbose)
* const userServiceManual = service(UserService, async (ctx) => {
* const db = await ctx.resolve(DatabaseService);
* return new UserService(db, 5000);
* });
*
* // Auto approach (concise)
* const userServiceAuto = autoService(UserService, [DatabaseService, 5000]);
* ```
*
* @example With finalizer for cleanup
* ```typescript
* class DatabaseService extends Tag.Service('DatabaseService') {
* constructor(private connectionString: string) {
* super();
* }
*
* private connection: Connection | null = null;
*
* async connect() {
* this.connection = await createConnection(this.connectionString);
* }
*
* async disconnect() {
* if (this.connection) {
* await this.connection.close();
* this.connection = null;
* }
* }
* }
*
* // Service with automatic cleanup
* const dbService = autoService(
* DatabaseService,
* {
* dependencies: ['postgresql://localhost:5432/mydb'],
* finalizer: (service) => service.disconnect() // Finalizer for cleanup
* }
* );
* ```
*/
function autoService(tag, spec) {
if (Array.isArray(spec)) spec = { dependencies: spec };
const factory = async (ctx) => {
const diDeps = [];
for (const dep of spec.dependencies) if (Tag.isTag(dep)) diDeps.push(dep);
const resolved = await ctx.resolveAll(...diDeps);
const args = [];
let resolvedIndex = 0;
for (const dep of spec.dependencies) if (Tag.isTag(dep)) args.push(resolved[resolvedIndex++]);
else args.push(dep);
return new tag(...args);
};
const finalSpec = spec.finalizer ? {
factory,
finalizer: spec.finalizer
} : factory;
return service(tag, finalSpec);
}
//#endregion
//#region src/value.ts
/**
* Creates a layer that provides a constant value for a given tag.
*
* @param tag - The value tag to provide
* @param constantValue - The constant value to provide
* @returns A layer with no dependencies that provides the constant value
*
* @example
* ```typescript
* const ApiKey = Tag.of('ApiKey')<string>();
* const DatabaseUrl = Tag.of('DatabaseUrl')<string>();
*
* const apiKey = value(ApiKey, 'my-secret-key');
* const dbUrl = value(DatabaseUrl, 'postgresql://localhost:5432/myapp');
*
* const config = Layer.merge(apiKey, dbUrl);
* ```
*/
function value(tag, constantValue) {
return layer((container) => container.register(tag, () => constantValue));
}
//#endregion
export { CircularDependencyError, Container, ContainerDestroyedError, ContainerError, DependencyAlreadyInstantiatedError, DependencyCreationError, DependencyFinalizationError, InjectSource, Layer, ScopedContainer, Tag, UnknownDependencyError, autoService, layer, scoped, service, value };