/** * Factory function type that creates a service instance. * @template T - The type of service being created * @template TContainer - The type of the container being injected */ export type ServiceFactory = (container: TContainer) => T; /** * Extender function type that modifies an existing service. * @template T - The type of service being extended * @template TContainer - The type of the container being injected */ export type ServiceExtender = ( original: T, container: TContainer, ) => T; /** * Interface for service providers that can register services with a container. * @template TMap - The service map type extending ServiceMap */ export interface ServiceProvider { /** * Registers services with the provided container. * @param container - The container to register services with */ register(container: JimpleWithProxy): void; } /** * Interface for async service providers that can register services with a container. * @template TMap - The service map type extending ServiceMap */ export interface AsyncServiceProvider { /** * Registers services asynchronously with the provided container. * @param container - The container to register services with */ registerAsync(container: JimpleWithProxy): Promise; } /** * Base interface for service mapping. Extend this interface to define your service types. * @example * ```typescript * interface MyServiceMap extends ServiceMap { * userService: UserService; * logger: Logger; * } * ``` */ export interface ServiceMap {} /** * Utility type to extract the service type from a service map. * @template TMap - The service map * @template TKey - The key in the service map */ export type ServiceType = TMap[TKey]; /** * Type for initial service registration, allowing either concrete instances or factory functions. * @template TMap - The service map * @template TContainer - The container type */ export type InitialServiceMap = { [TKey in keyof TMap]: | ServiceType | ServiceFactory, TContainer>; }; /** * Proxy-enhanced Jimple container with direct property access to services. * @template TMap - The service map extending ServiceMap */ export type JimpleWithProxy = Jimple & { readonly [TKey in keyof TMap]: ServiceType; }; /** * Assertion function that throws an error if the condition is false. * @param ok - The condition to assert * @param message - Error message to throw if condition is false * @throws {Error} When the assertion fails * @internal */ function assert(ok: boolean, message: string): asserts ok { if (!ok) { throw new Error(message); } } /** * Type guard to check if a value is a regular function. * @param fn - The value to check * @returns True if the value is a function * @internal */ function isFunction(fn: unknown): fn is Function { return ( Object.prototype.toString.call(fn) === "[object Function]" && (fn as any).constructor.name === "Function" ); } /** * Type guard to check if a value is an async function. * @param fn - The value to check * @returns True if the value is an async function * @internal */ function isAsyncFunction(fn: unknown): fn is Function { return ( Object.prototype.toString.call(fn) === "[object AsyncFunction]" && (fn as any).constructor.name === "AsyncFunction" ); } /** * Type guard to check if a value is a plain object (not an array, function, etc.). * @param value - The value to check * @returns True if the value is a plain object * @internal */ function isPlainObject(value: unknown): value is Record { if (Object.prototype.toString.call(value) !== "[object Object]") { return false; } const prototype = Object.getPrototypeOf(value); return prototype === null || prototype === Object.prototype; } /** * Checks if a value is a valid service definition (function or async function). * This is used to ensure that only valid service definitions are added to the container. * * @param fn - The value to check * @returns True if the value is an async function * @internal */ function isServiceDefinition(fn: unknown): fn is Function { return isFunction(fn) || isAsyncFunction(fn); } /** * Adds a function to a set after validating it's a proper function. * @template T - The function type * @param set - The set to add the function to * @param fn - The function to add * @throws {Error} When the value is not a function * @internal */ function addFunctionTo(set: Set, fn: T): void { assert(isServiceDefinition(fn), "Expected a function or async function."); set.add(fn); } /** * The Jimple Container class with TypeScript support. * A dependency injection container that manages services and parameters. * * @template TMap - The service map extending ServiceMap * * @example * ```typescript * interface MyServices extends ServiceMap { * logger: Logger; * userService: UserService; * } * * const container = Jimple.create(); * container.set('logger', () => new ConsoleLogger()); * container.set('userService', (c) => new UserService(c.logger)); * * const userService = container.userService; // Typed as UserService * ``` */ export default class Jimple { /** Internal storage for service definitions and parameters */ private readonly _items: Record = {}; /** Cache for instantiated services */ private readonly _instances = new Map(); /** Set of functions marked as factories (always return new instances) */ private readonly _factories = new Set(); /** Set of functions marked as protected (returned as-is, not called) */ private readonly _protected = new Set(); /** Proxy-enhanced version of this container for property access */ private readonly _bind: JimpleWithProxy; /** * Creates a service provider object. * @template TMap - The service map * @param register - Function that registers services with a container * @returns A service provider object * * @example * ```typescript * const myProvider = Jimple.provider((container) => { * container.set('logger', () => new Logger()); * }); * ``` */ static provider( register: ServiceProvider["register"], ): ServiceProvider { return { register }; } /** * Creates an asynchronous service provider object. * @template TMap - The service map * @param registerAsync - Function that registers services with a container asynchronously * @returns A service provider object * * @example * ```typescript * const myProvider = Jimple.providerAsync(async (container) => { * container.set('config', await initLogger()); * }); * ``` */ static providerAsync( registerAsync: AsyncServiceProvider["registerAsync"], ): AsyncServiceProvider { return { registerAsync }; } /** * Creates a new Jimple container instance with proxy support. * @template TMap - The service map * @param values - Initial services and parameters to register * @returns A proxy-enhanced container * * @example * ```typescript * const container = Jimple.create({ * logger: () => new ConsoleLogger(), * apiUrl: 'https://api.example.com' * }); * ``` */ static create( values?: InitialServiceMap>, ): JimpleWithProxy { return new this(values) as JimpleWithProxy; } /** * Create a Jimple Container. * @param values - Initial services and parameters to register * * @example * ```typescript * const container = new Jimple({ * config: { apiUrl: 'https://api.example.com' }, * logger: (c) => new Logger(c.config) * }); * ``` */ constructor( values?: Partial>>, ) { if (isPlainObject(values)) { Object.keys(values).forEach((key) => { const value = values[key as keyof TMap]; if (typeof value !== "undefined") { this.set(key as keyof TMap, value as any); } }); } this._bind = new Proxy(this, { get(target: Jimple, prop: string | symbol): any { if (prop in target && typeof prop === "string") { const value = (target as any)[prop]; if (isFunction(value) || isAsyncFunction(value)) { return value.bind(target); } } return target.get(prop as keyof TMap); }, set(target: Jimple, prop: string | symbol, value: any): boolean { assert( !(prop in target) || typeof prop !== "string", `Cannot override method '${prop as string}'. Use set() instead.`, ); target.set(prop as keyof TMap, value); return true; }, has(target: Jimple, prop: string | symbol): boolean { if (prop in target) { return true; } return target.has(prop as keyof TMap); }, deleteProperty(target: Jimple, prop: string | symbol): boolean { assert( !(prop in target) || typeof prop !== "string", `Cannot delete method '${prop as string}'. Use unset() instead.`, ); target.unset(prop as keyof TMap); return true; }, ownKeys(target: Jimple): ArrayLike { const classKeys = Object.getOwnPropertyNames(target); const serviceKeys = target.keys() as string[]; return [...new Set([...classKeys, ...serviceKeys])]; }, getOwnPropertyDescriptor(target: Jimple, prop: string | symbol) { if (prop in target) { return Object.getOwnPropertyDescriptor(target, prop); } if (typeof prop === "string" && target.has(prop as keyof TMap)) { return { enumerable: true, configurable: true, get: () => target.get(prop as keyof TMap), set: (value: any) => target.set(prop as keyof TMap, value), }; } return undefined; }, }) as JimpleWithProxy; return this._bind; } /** * Return the specified parameter or service with correct typing. * Services defined as functions are instantiated when first accessed (singleton pattern). * Services marked as factories are instantiated on every access. * Services marked as protected are returned as-is without being called. * * @template TKey - The service key type * @param key - The service key to retrieve * @returns The service instance or parameter value * @throws {Error} When the service is not defined * * @example * ```typescript * const logger = container.get('logger'); * const apiUrl = container.get('apiUrl'); * ``` */ get(key: TKey): ServiceType { const item = this.raw(key); if (isServiceDefinition(item)) { if (this._protected.has(item)) { return item as ServiceType; } else if (this._instances.has(item)) { return this._instances.get(item) as ServiceType; } const obj = item(this._bind); if (!this._factories.has(item)) { this._instances.set(item, obj); } return obj; } return item; } /** * Defines a new parameter or service. * Functions are treated as service factories unless marked as protected. * * @template TKey - The service key type * @param key - The service key * @param value - The service value, instance, or factory function * @throws {Error} When trying to redefine an already instantiated service * * @example * ```typescript * // Parameter * container.set('apiUrl', 'https://api.example.com'); * * // Service factory * container.set('logger', (c) => new Logger(c.apiUrl)); * * // Service instance * container.set('cache', new MemoryCache()); * ``` */ set( key: TKey, value: ServiceFactory, JimpleWithProxy>, ): void; set(key: TKey, value: ServiceType): void; set( key: TKey, value: | ServiceType | ServiceFactory, JimpleWithProxy>, ): void { const originalItem = this._items[key as string]; assert( !isServiceDefinition(originalItem) || !this._instances.has(originalItem), `Service '${key as string}' already instantiated and cannot be redefined.`, ); this._items[key as string] = value; } /** * Unsets a parameter or service, removing it from the container. * Also clears any cached instances and metadata for the service. * * @template TKey - The service key type * @param key - The service key to remove * * @example * ```typescript * container.unset('logger'); * console.log(container.has('logger')); // false * ``` */ unset(key: TKey): void { const item = this._items[key as string]; if (isServiceDefinition(item)) { this._instances.delete(item); this._factories.delete(item); this._protected.delete(item); } delete this._items[key as string]; } /** * Returns if a service or parameter is defined in the container. * * @template TKey - The service key type * @param key - The service key to check * @returns True if the service is defined * * @example * ```typescript * if (container.has('logger')) { * const logger = container.get('logger'); * } * ``` */ has(key: TKey): boolean { return Object.prototype.hasOwnProperty.call(this._items, key as string); } /** * Defines a service as a factory that creates new instances on every access. * Unlike regular services (which are singletons), factories always call the function. * * @template TKey - The service key type * @template T - The factory function type * @param fn - The factory function to mark * @returns The same function (for chaining) * @throws {Error} When the value is not a function * * @example * ```typescript * container.set('requestId', container.factory(() => Math.random().toString())); * * const id1 = container.get('requestId'); // Different value each time * const id2 = container.get('requestId'); // Different value each time * ``` */ factory< TKey extends keyof TMap, T extends ServiceFactory, JimpleWithProxy>, >(fn: T): T { addFunctionTo(this._factories, fn); return fn; } /** * Defines a function as a parameter (protected from being called as a service factory). * The function will be returned as-is when accessed, not executed. * * @template T - The function type * @param fn - The function to protect * @returns The same function (for chaining) * @throws {Error} When the value is not a function * * @example * ```typescript * const callback = (data: any) => console.log(data); * container.set('onComplete', container.protect(callback)); * * const fn = container.get('onComplete'); // Returns the function itself * fn('Hello'); // Can be called later * ``` */ protect>( fn: T extends (...args: any[]) => any ? T : never, ): T { addFunctionTo(this._protected, fn); return fn; } /** * Return all the keys registered in the container. * * @returns Array of all service keys * * @example * ```typescript * const keys = container.keys(); * console.log('Registered services:', keys); * ``` */ keys(): (keyof TMap)[] { return Object.keys(this._items) as (keyof TMap)[]; } /** * Extends a service already registered in the container. * Allows decorating or modifying an existing service definition. * * @template TKey - The service key type * @template TResult - The extended service type * @param key - The service key to extend * @param fn - Function that receives the original service and returns the extended version * @throws {Error} When the service is not defined, not a function, protected, or already instantiated * * @example * ```typescript * container.set('logger', () => new Logger()); * * container.extend('logger', (logger, c) => { * return new LoggerDecorator(logger, c.config); * }); * ``` */ extend( key: TKey, fn: ServiceExtender, JimpleWithProxy>, ): void { const originalItem = this.raw(key); assert( isServiceDefinition(originalItem) && !this._protected.has(originalItem), `Service '${key as string}' is not extendable.`, ); assert( isServiceDefinition(fn), `Extension for '${key as string}' must be a function.`, ); assert( !this._instances.has(originalItem), `Service '${key as string}' already instantiated and cannot be extended.`, ); type ServiceExtenderWrapper = typeof originalItem & { _: (typeof fn)[] }; const maybeList = (originalItem as ServiceExtenderWrapper)._; if (maybeList) { maybeList.push(fn); return; } const wrapper = (this._items[key as string] = ( app: JimpleWithProxy, ) => wrapper._.reduce( (result, extend) => extend(result, app), originalItem(app), )) as ServiceExtenderWrapper; wrapper._ = [fn]; const { _factories } = this; if (_factories.delete(originalItem)) { _factories.add(wrapper); } } /** * Uses a provider to extend the service container. * Providers are objects with a register method that can add multiple services. * * @template K - The subset of service keys the provider manages * @param provider - The service provider to register * * @example * ```typescript * const databaseProvider = Jimple.provider((container) => { * container.set('db', () => new Database(container.config)); * container.set('userRepo', (c) => new UserRepository(c.db)); * }); * * container.register(databaseProvider); * ``` */ register( provider: ServiceProvider>, ): void { return provider.register( this._bind as unknown as JimpleWithProxy>, ); } /** * Uses an async provider to extend the service container. * Providers are objects with a register method that can add multiple services and returns a Promise. * * @template K - The subset of service keys the provider manages * @param provider - The asynchronous service provider to register * * @example * ```typescript * const databaseProvider = Jimple.providerAsync(async (container) => { * container.set("config", await initDb()); * container.set('db', () => new Database(container.config)); * container.set('userRepo', (c) => new UserRepository(c.db)); * }); * * container.registerAsync(databaseProvider); * ``` */ registerAsync( provider: AsyncServiceProvider>, ): Promise { return new Promise((resolve, reject) => provider .registerAsync(this._bind as unknown as JimpleWithProxy>) .then(() => resolve(), reject), ); } /** * Returns the raw value of a service or parameter without instantiation. * For services defined as functions, returns the function itself rather than calling it. * * @template TKey - The service key type * @param key - The service key * @returns The raw service definition or parameter value * @throws {Error} When the service is not defined * * @example * ```typescript * const factory = container.raw('logger'); // Returns the factory function * const logger = factory(container); // Manually instantiate * ``` */ raw( key: TKey, ): | ServiceType | ServiceFactory, JimpleWithProxy> { assert(this.has(key), `Service "${key as string}" not found.`); return this._items[key as string] as | ServiceType | ServiceFactory, JimpleWithProxy>; } }