UNPKG

sandly

Version:

**⚠️ This project is under heavy development and APIs may change.**

1,264 lines (1,255 loc) 42.8 kB
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 };