UNPKG

contexify

Version:

A TypeScript library providing a powerful dependency injection container with context-based IoC capabilities, inspired by LoopBack's Context system.

615 lines 17.9 kB
var __defProp = Object.defineProperty; var __name = (target, value) => __defProp(target, "name", { value, configurable: true }); import { EventEmitter } from "events"; import { inspectInjections } from "../inject/inject.js"; import { createProxyWithInterceptors } from "../interceptor/interception-proxy.js"; import { invokeMethod } from "../interceptor/invocation.js"; import { asResolutionOptions, ResolutionError, ResolutionSession } from "../resolution/resolution-session.js"; import { instantiateClass } from "../resolution/resolver.js"; import createDebugger from "../utils/debug.js"; import { ContextTags } from "../utils/keys.js"; import { isPromiseLike, transformValueOrPromise } from "../utils/value-promise.js"; import { bindingTemplateFor } from "./binding-inspector.js"; import { BindingKey } from "./binding-key.js"; const debug = createDebugger("contexify:binding"); var BindingScope = /* @__PURE__ */ function(BindingScope2) { BindingScope2["TRANSIENT"] = "Transient"; BindingScope2["CONTEXT"] = "Context"; BindingScope2["SINGLETON"] = "Singleton"; BindingScope2["APPLICATION"] = "Application"; BindingScope2["SERVER"] = "Server"; BindingScope2["REQUEST"] = "Request"; return BindingScope2; }({}); var BindingType = /* @__PURE__ */ function(BindingType2) { BindingType2["CONSTANT"] = "Constant"; BindingType2["DYNAMIC_VALUE"] = "DynamicValue"; BindingType2["CLASS"] = "Class"; BindingType2["PROVIDER"] = "Provider"; BindingType2["ALIAS"] = "Alias"; return BindingType2; }({}); function toValueFactory(provider) { return (resolutionCtx) => invokeMethod(provider, "value", resolutionCtx.context, [], { skipInterceptors: true, session: resolutionCtx.options.session }); } __name(toValueFactory, "toValueFactory"); function isDynamicValueProviderClass(factory) { if (typeof factory !== "function" || !String(factory).startsWith("class ")) { return false; } const valueMethod = factory.value; return typeof valueMethod === "function"; } __name(isDynamicValueProviderClass, "isDynamicValueProviderClass"); class Binding extends EventEmitter { static { __name(this, "Binding"); } isLocked; /** * Key of the binding */ key; /** * Map for tag name/value pairs */ tagMap = {}; _scope; /** * Scope of the binding to control how the value is cached/shared */ get scope() { return this._scope ?? "Transient"; } /** * Type of the binding value getter */ get type() { return this._source?.type; } _cache = /* @__PURE__ */ new WeakMap(); _getValue; /** * The original source value received from `to`, `toClass`, `toDynamicValue`, * `toProvider`, or `toAlias`. */ _source; get source() { return this._source; } /** * For bindings bound via `toClass()`, this property contains the constructor * function of the class */ get valueConstructor() { return this._source?.type === "Class" ? this._source?.value : void 0; } /** * For bindings bound via `toProvider()`, this property contains the * constructor function of the provider class */ get providerConstructor() { return this._source?.type === "Provider" ? this._source?.value : void 0; } constructor(key, isLocked = false) { super(), this.isLocked = isLocked; BindingKey.validate(key); this.key = key.toString(); } /** * Cache the resolved value by the binding scope * @param resolutionCtx - The resolution context * @param result - The calculated value for the binding */ _cacheValue(resolutionCtx, result) { if (!this._cache) this._cache = /* @__PURE__ */ new WeakMap(); if (this.scope !== "Transient") { this._cache.set(resolutionCtx, result); } return result; } /** * Clear the cache */ _clearCache() { if (!this._cache) return; this._cache = /* @__PURE__ */ new WeakMap(); } /** * Invalidate the binding cache so that its value will be reloaded next time. * This is useful to force reloading a cached value when its configuration or * dependencies are changed. * **WARNING**: The state held in the cached value will be gone. * * @param ctx - Context object */ refresh(ctx) { if (!this._cache) return; if (this.scope !== "Transient") { const resolutionCtx = ctx.getResolutionContext(this); if (resolutionCtx != null) { this._cache.delete(resolutionCtx); } } } // Implementation getValue(ctx, optionsOrSession) { if (debug.enabled) { debug("Get value for binding %s", this.key); } const options = asResolutionOptions(optionsOrSession); const resolutionCtx = this.getResolutionContext(ctx, options); if (resolutionCtx == null) return void 0; const savedSession = ResolutionSession.fork(options.session) ?? new ResolutionSession(); if (this._cache) { if (this.scope !== "Transient") { if (resolutionCtx && this._cache.has(resolutionCtx)) { const value = this._cache.get(resolutionCtx); return this.getValueOrProxy(resolutionCtx, { ...options, session: savedSession }, value); } } } const resolutionMetadata = { context: resolutionCtx, binding: this, options }; if (typeof this._getValue === "function") { const result = ResolutionSession.runWithBinding((s) => { const optionsWithSession = { ...options, session: s, // Force to be the non-proxy version asProxyWithInterceptors: false }; return this._getValue({ ...resolutionMetadata, options: optionsWithSession }); }, this, options.session); const value = this._cacheValue(resolutionCtx, result); return this.getValueOrProxy(resolutionCtx, { ...options, session: savedSession }, value); } if (options.optional) return void 0; return Promise.reject(new ResolutionError(`No value was configured for binding ${this.key}.`, resolutionMetadata)); } getValueOrProxy(resolutionCtx, options, value) { const session = options.session; session.pushBinding(this); return Binding.valueOrProxy({ context: resolutionCtx, binding: this, options }, value); } /** * Locate and validate the resolution context * @param ctx - Current context * @param options - Resolution options */ getResolutionContext(ctx, options) { const resolutionCtx = ctx.getResolutionContext(this); switch (this.scope) { case "Application": case "Server": case "Request": if (resolutionCtx == null) { const msg = `Binding "${this.key}" in context "${ctx.name}" cannot be resolved in scope "${this.scope}"`; if (options.optional) { if (debug.enabled) { debug(msg); } return void 0; } throw new Error(msg); } } const ownerCtx = ctx.getOwnerContext(this.key); if (ownerCtx != null && !ownerCtx.isVisibleTo(resolutionCtx)) { const msg = `Resolution context "${resolutionCtx?.name}" does not have visibility to binding "${this.key} (scope:${this.scope})" in context "${ownerCtx.name}"`; if (options.optional) { if (debug.enabled) { debug(msg); } return void 0; } throw new Error(msg); } return resolutionCtx; } /** * Lock the binding so that it cannot be rebound */ lock() { this.isLocked = true; return this; } /** * Emit a `changed` event * @param operation - Operation that makes changes */ emitChangedEvent(operation) { const event = { binding: this, operation, type: "changed" }; this.emit("changed", event); } /** * Tag the binding with names or name/value objects. A tag has a name and * an optional value. If not supplied, the tag name is used as the value. * * @param tags - A list of names or name/value objects. Each * parameter can be in one of the following forms: * - string: A tag name without value * - string[]: An array of tag names * - TagMap: A map of tag name/value pairs * * @example * ```ts * // Add a named tag `controller` * binding.tag('controller'); * * // Add two named tags: `controller` and `rest` * binding.tag('controller', 'rest'); * * // Add two tags * // - `controller` (name = 'controller') * // `{name: 'my-controller'}` (name = 'name', value = 'my-controller') * binding.tag('controller', {name: 'my-controller'}); * * ``` */ tag(...tags) { for (const t of tags) { if (typeof t === "string") { this.tagMap[t] = t; } else if (Array.isArray(t)) { throw new Error("Tag must be a string or an object (but not array): " + t); } else { Object.assign(this.tagMap, t); } } this.emitChangedEvent("tag"); return this; } /** * Get an array of tag names */ get tagNames() { return Object.keys(this.tagMap); } /** * Set the binding scope * @param scope - Binding scope */ inScope(scope) { if (this._scope !== scope) this._clearCache(); this._scope = scope; this.emitChangedEvent("scope"); return this; } /** * Apply default scope to the binding. It only changes the scope if it's not * set yet * @param scope - Default binding scope */ applyDefaultScope(scope) { if (!this._scope) { this.inScope(scope); } return this; } /** * Set the `_getValue` function * @param getValue - getValue function */ _setValueGetter(getValue) { this._clearCache(); this._getValue = (resolutionCtx) => { return getValue(resolutionCtx); }; this.emitChangedEvent("value"); } /** * Bind the key to a constant value. The value must be already available * at binding time, it is not allowed to pass a Promise instance. * * @param value - The bound value. * * @example * * ```ts * ctx.bind('appName').to('CodeHub'); * ``` */ to(value) { if (isPromiseLike(value)) { throw new Error('Promise instances are not allowed for constant values bound via ".to()". Register an async getter function via ".toDynamicValue()" instead.'); } if (debug.enabled) { debug("Bind %s to constant:", this.key, value); } this._source = { type: "Constant", value }; this._setValueGetter((resolutionCtx) => { return Binding.valueOrProxy(resolutionCtx, value); }); return this; } /** * Bind the key to a computed (dynamic) value. * * @param factoryFn - The factory function creating the value. * Both sync and async functions are supported. * * @example * * ```ts * // synchronous * ctx.bind('now').toDynamicValue(() => Date.now()); * * // asynchronous * ctx.bind('something').toDynamicValue( * async () => Promise.delay(10).then(doSomething) * ); * ``` */ toDynamicValue(factory) { if (debug.enabled) { debug("Bind %s to dynamic value:", this.key, factory); } this._source = { type: "DynamicValue", value: factory }; let factoryFn; if (isDynamicValueProviderClass(factory)) { factoryFn = toValueFactory(factory); } else { factoryFn = factory; } this._setValueGetter((resolutionCtx) => { const value = factoryFn(resolutionCtx); return Binding.valueOrProxy(resolutionCtx, value); }); return this; } static valueOrProxy(resolutionCtx, value) { if (!resolutionCtx.options.asProxyWithInterceptors) return value; return createInterceptionProxyFromInstance(value, resolutionCtx.context, resolutionCtx.options.session); } /** * Bind the key to a value computed by a Provider. * * * @example * * ```ts * export class DateProvider implements Provider<Date> { * constructor(@inject('stringDate') private param: String){} * value(): Date { * return new Date(param); * } * } * ``` * * @param provider - The value provider to use. */ toProvider(providerClass) { if (debug.enabled) { debug("Bind %s to provider %s", this.key, providerClass.name); } this._source = { type: "Provider", value: providerClass }; this._setValueGetter((resolutionCtx) => { const providerOrPromise = instantiateClass(providerClass, resolutionCtx.context, resolutionCtx.options.session); const value = transformValueOrPromise(providerOrPromise, (p) => p.value()); return Binding.valueOrProxy(resolutionCtx, value); }); return this; } /** * Bind the key to an instance of the given class. * * @param ctor - The class constructor to call. Any constructor * arguments must be annotated with `@inject` so that * we can resolve them from the context. */ toClass(ctor) { if (debug.enabled) { debug("Bind %s to class %s", this.key, ctor.name); } this._source = { type: "Class", value: ctor }; this._setValueGetter((resolutionCtx) => { const value = instantiateClass(ctor, resolutionCtx.context, resolutionCtx.options.session); return Binding.valueOrProxy(resolutionCtx, value); }); return this; } /** * Bind to a class optionally decorated with `@injectable`. Based on the * introspection of the class, it calls `toClass/toProvider/toDynamicValue` * internally. The current binding key will be preserved (not being overridden * by the key inferred from the class or options). * * This is similar to {@link createBindingFromClass} but applies to an * existing binding. * * @example * * ```ts * @injectable({scope: BindingScope.SINGLETON, tags: {service: 'MyService}}) * class MyService { * // ... * } * * const ctx = new Context(); * ctx.bind('services.MyService').toInjectable(MyService); * ``` * * @param ctor - A class decorated with `@injectable`. */ toInjectable(ctor) { this.apply(bindingTemplateFor(ctor)); return this; } /** * Bind the key to an alias of another binding * @param keyWithPath - Target binding key with optional path, * such as `servers.RestServer.options#apiExplorer` */ toAlias(keyWithPath) { if (debug.enabled) { debug("Bind %s to alias %s", this.key, keyWithPath); } this._source = { type: "Alias", value: keyWithPath }; this._setValueGetter(({ context, options }) => { return context.getValueOrPromise(keyWithPath, options); }); return this; } /** * Unlock the binding */ unlock() { this.isLocked = false; return this; } /** * Apply one or more template functions to set up the binding with scope, * tags, and other attributes as a group. * * @example * ```ts * const serverTemplate = (binding: Binding) => * binding.inScope(BindingScope.SINGLETON).tag('server'); * * const serverBinding = new Binding<RestServer>('servers.RestServer1'); * serverBinding.apply(serverTemplate); * ``` * @param templateFns - One or more functions to configure the binding */ apply(...templateFns) { for (const fn of templateFns) { fn(this); } return this; } /** * Convert to a plain JSON object */ toJSON() { const json = { key: this.key, scope: this.scope, tags: this.tagMap, isLocked: this.isLocked }; if (this.type != null) { json.type = this.type; } switch (this._source?.type) { case "Class": json.valueConstructor = this._source?.value.name; break; case "Provider": json.providerConstructor = this._source?.value.name; break; case "Alias": json.alias = this._source?.value.toString(); break; } return json; } /** * Inspect the binding to return a json representation of the binding information * @param options - Options to control what information should be included */ inspect(options = {}) { options = { includeInjections: false, ...options }; const json = this.toJSON(); if (options.includeInjections) { const injections = inspectInjections(this); if (Object.keys(injections).length) json.injections = injections; } return json; } /** * A static method to create a binding so that we can do * `Binding.bind('foo').to('bar');` as `new Binding('foo').to('bar')` is not * easy to read. * @param key - Binding key */ static bind(key) { return new Binding(key); } /** * Create a configuration binding for the given key * * @example * ```ts * const configBinding = Binding.configure('servers.RestServer.server1') * .to({port: 3000}); * ``` * * @typeParam V Generic type for the configuration value (not the binding to * be configured) * * @param key - Key for the binding to be configured */ static configure(key) { return new Binding(BindingKey.buildKeyForConfig(key)).tag({ [ContextTags.CONFIGURATION_FOR]: key.toString() }); } // eslint-disable-next-line @typescript-eslint/no-explicit-any on(event, listener) { return super.on(event, listener); } // eslint-disable-next-line @typescript-eslint/no-explicit-any once(event, listener) { return super.once(event, listener); } } function createInterceptionProxyFromInstance(instOrPromise, context, session) { return transformValueOrPromise(instOrPromise, (inst) => { if (typeof inst !== "object" || inst == null) return inst; return createProxyWithInterceptors( // Cast inst from `T` to `object` inst, context, session ); }); } __name(createInterceptionProxyFromInstance, "createInterceptionProxyFromInstance"); export { Binding, BindingScope, BindingType, isDynamicValueProviderClass }; //# sourceMappingURL=binding.js.map