UNPKG

contexify

Version:

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

692 lines 21.9 kB
var __defProp = Object.defineProperty; var __name = (target, value) => __defProp(target, "name", { value, configurable: true }); import { EventEmitter } from "events"; import { Binding, BindingScope } from "../binding/binding.js"; import { DefaultConfigurationResolver } from "../binding/binding-config.js"; import { filterByKey, filterByTag, isBindingTagFilter } from "../binding/binding-filter.js"; import { BindingKey } from "../binding/binding-key.js"; import { asResolutionOptions, ResolutionError } from "../resolution/resolution-session.js"; import createDebugger from "../utils/debug.js"; import { ContextBindings } from "../utils/keys.js"; import { generateUniqueId } from "../utils/unique-id.js"; import { getDeepProperty, isPromiseLike, transformValueOrPromise } from "../utils/value-promise.js"; import { ContextSubscriptionManager } from "./context-subscription.js"; import { ContextTagIndexer } from "./context-tag-indexer.js"; import { ContextView } from "./context-view.js"; class Context extends EventEmitter { static { __name(this, "Context"); } /** * Name of the context */ name; /** * Key to binding map as the internal registry */ registry = /* @__PURE__ */ new Map(); /** * Indexer for bindings by tag */ tagIndexer; /** * Manager for observer subscriptions */ subscriptionManager; /** * Parent context */ _parent; /** * Configuration resolver */ configResolver; /** * A logger function which can be overridden by subclasses. * * @example * ```ts * import createDebugger from '../utils/debug.js'; * const debugger = createDebugger('contexify:application'); * export class Application extends Context { * super('application'); * this._debug = debugger; * } * ``` */ _debug; /** * Scope for binding resolution */ scope = BindingScope.CONTEXT; /** * Create a new context. * * @example * ```ts * // Create a new root context, let the framework to create a unique name * const rootCtx = new Context(); * * // Create a new child context inheriting bindings from `rootCtx` * const childCtx = new Context(rootCtx); * * // Create another root context called "application" * const appCtx = new Context('application'); * * // Create a new child context called "request" and inheriting bindings * // from `appCtx` * const reqCtx = new Context(appCtx, 'request'); * ``` * @param _parent - The optional parent context * @param name - Name of the context. If not provided, a unique identifier * will be generated as the name. */ constructor(_parent, name) { super(); this.setMaxListeners(Number.POSITIVE_INFINITY); if (typeof _parent === "string") { name = _parent; _parent = void 0; } this._parent = _parent; this.name = name ?? this.generateName(); this.tagIndexer = new ContextTagIndexer(this); this.subscriptionManager = new ContextSubscriptionManager(this); this._debug = createDebugger(this.getDebugNamespace()); } /** * Get the debug namespace for the context class. Subclasses can override * this method to supply its own namespace. * * @example * ```ts * export class Application extends Context { * super('application'); * } * * protected getDebugNamespace() { * return 'contexify:application'; * } * ``` */ getDebugNamespace() { if (this.constructor === Context) return "contexify"; const name = this.constructor.name.toLowerCase(); return `contexify:${name}`; } generateName() { const id = generateUniqueId(); if (this.constructor === Context) return id; return `${this.constructor.name}-${id}`; } /** * @internal * Getter for ContextSubscriptionManager */ get parent() { return this._parent; } /** * Wrap the debug statement so that it always print out the context name * as the prefix * @param args - Arguments for the debug */ debug(...args) { if (!this._debug.enabled) return; const formatter = args.shift(); if (typeof formatter === "string") { this._debug(`[%s] ${formatter}`, this.name, ...args); } else { this._debug("[%s] ", this.name, formatter, ...args); } } /** * A strongly-typed method to emit context events * @param type Event type * @param event Context event */ emitEvent(type, event) { this.emit(type, event); } /** * Emit an `error` event * @param err Error */ emitError(err) { this.emit("error", err); } /** * Create a binding with the given key in the context. If a locked binding * already exists with the same key, an error will be thrown. * * @param key - Binding key */ bind(key) { const binding = new Binding(key.toString()); this.add(binding); return binding; } /** * Add a binding to the context. If a locked binding already exists with the * same key, an error will be thrown. * @param binding - The configured binding to be added */ add(binding) { const key = binding.key; this.debug("[%s] Adding binding: %s", key); let existingBinding; const keyExists = this.registry.has(key); if (keyExists) { existingBinding = this.registry.get(key); const bindingIsLocked = existingBinding?.isLocked; if (bindingIsLocked) throw new Error(`Cannot rebind key "${key}" to a locked binding`); } this.registry.set(key, binding); if (existingBinding !== binding) { if (existingBinding != null) { this.emitEvent("unbind", { binding: existingBinding, context: this, type: "unbind" }); } this.emitEvent("bind", { binding, context: this, type: "bind" }); } return this; } /** * Create a corresponding binding for configuration of the target bound by * the given key in the context. * * For example, `ctx.configure('controllers.MyController').to({x: 1})` will * create binding `controllers.MyController:$config` with value `{x: 1}`. * * @param key - The key for the binding to be configured */ configure(key = "") { const bindingForConfig = Binding.configure(key); this.add(bindingForConfig); return bindingForConfig; } /** * Get the value or promise of configuration for a given binding by key * * @param key - Binding key * @param propertyPath - Property path for the option. For example, `x.y` * requests for `<config>.x.y`. If not set, the `<config>` object will be * returned. * @param resolutionOptions - Options for the resolution. * - optional: if not set or set to `true`, `undefined` will be returned if * no corresponding value is found. Otherwise, an error will be thrown. */ getConfigAsValueOrPromise(key, propertyPath, resolutionOptions) { this.setupConfigurationResolverIfNeeded(); return this.configResolver.getConfigAsValueOrPromise(key, propertyPath, resolutionOptions); } /** * Set up the configuration resolver if needed */ setupConfigurationResolverIfNeeded() { if (!this.configResolver) { const configResolver = this.getSync(ContextBindings.CONFIGURATION_RESOLVER, { optional: true }); if (configResolver) { this.debug("Custom ConfigurationResolver is loaded from %s.", ContextBindings.CONFIGURATION_RESOLVER.toString()); this.configResolver = configResolver; } else { this.debug("DefaultConfigurationResolver is used."); this.configResolver = new DefaultConfigurationResolver(this); } } return this.configResolver; } /** * Resolve configuration for the binding by key * * @param key - Binding key * @param propertyPath - Property path for the option. For example, `x.y` * requests for `<config>.x.y`. If not set, the `<config>` object will be * returned. * @param resolutionOptions - Options for the resolution. */ async getConfig(key, propertyPath, resolutionOptions) { return this.getConfigAsValueOrPromise(key, propertyPath, resolutionOptions); } /** * Resolve configuration synchronously for the binding by key * * @param key - Binding key * @param propertyPath - Property path for the option. For example, `x.y` * requests for `config.x.y`. If not set, the `config` object will be * returned. * @param resolutionOptions - Options for the resolution. */ getConfigSync(key, propertyPath, resolutionOptions) { const valueOrPromise = this.getConfigAsValueOrPromise(key, propertyPath, resolutionOptions); if (isPromiseLike(valueOrPromise)) { const prop = propertyPath ? ` property ${propertyPath}` : ""; throw new Error(`Cannot get config${prop} for ${key} synchronously: the value is a promise`); } return valueOrPromise; } /** * Unbind a binding from the context. No parent contexts will be checked. * * @remarks * If you need to unbind a binding owned by a parent context, use the code * below: * * ```ts * const ownerCtx = ctx.getOwnerContext(key); * return ownerCtx != null && ownerCtx.unbind(key); * ``` * * @param key - Binding key * @returns true if the binding key is found and removed from this context */ unbind(key) { this.debug("Unbind %s", key); key = BindingKey.validate(key); const binding = this.registry.get(key); if (binding == null) return false; if (binding?.isLocked) throw new Error(`Cannot unbind key "${key}" of a locked binding`); this.registry.delete(key); this.emitEvent("unbind", { binding, context: this, type: "unbind" }); return true; } /** * Add a context event observer to the context * @param observer - Context observer instance or function */ subscribe(observer) { return this.subscriptionManager.subscribe(observer); } /** * Remove the context event observer from the context * @param observer - Context event observer */ unsubscribe(observer) { return this.subscriptionManager.unsubscribe(observer); } /** * Close the context: clear observers, stop notifications, and remove event * listeners from its parent context. * * @remarks * This method MUST be called to avoid memory leaks once a context object is * no longer needed and should be recycled. An example is the `RequestContext`, * which is created per request. */ close() { this.debug("Closing context..."); this.subscriptionManager.close(); this.tagIndexer.close(); } /** * Check if an observer is subscribed to this context * @param observer - Context observer */ isSubscribed(observer) { return this.subscriptionManager.isSubscribed(observer); } /** * Create a view of the context chain with the given binding filter * @param filter - A function to match bindings * @param comparator - A function to sort matched bindings * @param options - Resolution options */ createView(filter, comparator, options) { const view = new ContextView(this, filter, comparator, options); view.open(); return view; } /** * Check if a binding exists with the given key in the local context without * delegating to the parent context * @param key - Binding key */ contains(key) { key = BindingKey.validate(key); return this.registry.has(key); } /** * Check if a key is bound in the context or its ancestors * @param key - Binding key */ isBound(key) { if (this.contains(key)) return true; if (this._parent) { return this._parent.isBound(key); } return false; } /** * Get the owning context for a binding or its key * @param keyOrBinding - Binding object or key */ getOwnerContext(keyOrBinding) { let key; if (keyOrBinding instanceof Binding) { key = keyOrBinding.key; } else { key = keyOrBinding; } if (this.contains(key)) { if (keyOrBinding instanceof Binding) { if (this.registry.get(key.toString()) === keyOrBinding) { return this; } return void 0; } return this; } if (this._parent) { return this._parent.getOwnerContext(key); } return void 0; } /** * Get the context matching the scope * @param scope - Binding scope */ getScopedContext(scope) { if (this.scope === scope) return this; if (this._parent) { return this._parent.getScopedContext(scope); } return void 0; } /** * Locate the resolution context for the given binding. Only bindings in the * resolution context and its ancestors are visible as dependencies to resolve * the given binding * @param binding - Binding object */ getResolutionContext(binding) { let resolutionCtx; switch (binding.scope) { case BindingScope.SINGLETON: return this.getOwnerContext(binding.key); case BindingScope.TRANSIENT: case BindingScope.CONTEXT: return this; case BindingScope.REQUEST: resolutionCtx = this.getScopedContext(binding.scope); if (resolutionCtx != null) { return resolutionCtx; } this.debug('No context is found for binding "%s (scope=%s)". Fall back to the current context.', binding.key, binding.scope); return this; default: return this.getScopedContext(binding.scope); } } /** * Check if this context is visible (same or ancestor) to the given one * @param ctx - Another context object */ isVisibleTo(ctx) { let current = ctx; while (current != null) { if (current === this) return true; current = current._parent; } return false; } /** * Find bindings using a key pattern or filter function * @param pattern - A filter function, a regexp or a wildcard pattern with * optional `*` and `?`. Find returns such bindings where the key matches * the provided pattern. * * For a wildcard: * - `*` matches zero or more characters except `.` and `:` * - `?` matches exactly one character except `.` and `:` * * For a filter function: * - return `true` to include the binding in the results * - return `false` to exclude it. */ find(pattern) { if (typeof pattern === "function" && isBindingTagFilter(pattern)) { return this._findByTagIndex(pattern.bindingTagPattern); } const bindings = []; const filter = filterByKey(pattern); for (const b of this.registry.values()) { if (filter(b)) bindings.push(b); } const parentBindings = this._parent?.find(filter); return this._mergeWithParent(bindings, parentBindings); } /** * Find bindings using the tag filter. If the filter matches one of the * binding tags, the binding is included. * * @param tagFilter - A filter for tags. It can be in one of the following * forms: * - A regular expression, such as `/controller/` * - A wildcard pattern string with optional `*` and `?`, such as `'con*'` * For a wildcard: * - `*` matches zero or more characters except `.` and `:` * - `?` matches exactly one character except `.` and `:` * - An object containing tag name/value pairs, such as * `{name: 'my-controller'}` */ findByTag(tagFilter) { return this.find(filterByTag(tagFilter)); } /** * Find bindings by tag leveraging indexes * @param tag - Tag name pattern or name/value pairs */ _findByTagIndex(tag) { const currentBindings = this.tagIndexer.findByTagIndex(tag); const parentBindings = this._parent?._findByTagIndex(tag); return this._mergeWithParent(currentBindings, parentBindings); } _mergeWithParent(childList, parentList) { if (!parentList) return childList; const additions = parentList.filter((parentBinding) => { return !childList.some((childBinding) => childBinding.key === parentBinding.key); }); return childList.concat(additions); } // Implementation async get(keyWithPath, optionsOrSession) { this.debug("Resolving binding: %s", keyWithPath); return this.getValueOrPromise(keyWithPath, optionsOrSession); } // Implementation getSync(keyWithPath, optionsOrSession) { this.debug("Resolving binding synchronously: %s", keyWithPath); const valueOrPromise = this.getValueOrPromise(keyWithPath, optionsOrSession); if (isPromiseLike(valueOrPromise)) { throw new Error(`Cannot get ${keyWithPath} synchronously: the value is a promise`); } return valueOrPromise; } getBinding(key, options) { key = BindingKey.validate(key); const binding = this.registry.get(key); if (binding) { return binding; } if (this._parent) { return this._parent.getBinding(key, options); } if (options?.optional) return void 0; throw new Error(`The key '${key}' is not bound to any value in context ${this.name}`); } /** * Find or create a binding for the given key * @param key - Binding address * @param policy - Binding creation policy */ findOrCreateBinding(key, policy) { let binding; if (policy === "Always") { binding = this.bind(key); } else if (policy === "Never") { binding = this.getBinding(key); } else if (this.isBound(key)) { binding = this.getBinding(key); } else { binding = this.bind(key); } return binding; } /** * Get the value bound to the given key. * * This is an internal version that preserves the dual sync/async result * of `Binding#getValue()`. Users should use `get()` or `getSync()` instead. * * @example * * ```ts * // get the value bound to "application.instance" * ctx.getValueOrPromise<Application>('application.instance'); * * // get "rest" property from the value bound to "config" * ctx.getValueOrPromise<RestComponentConfig>('config#rest'); * * // get "a" property of "numbers" property from the value bound to "data" * ctx.bind('data').to({numbers: {a: 1, b: 2}, port: 3000}); * ctx.getValueOrPromise<number>('data#numbers.a'); * ``` * * @param keyWithPath - The binding key, optionally suffixed with a path to the * (deeply) nested property to retrieve. * @param optionsOrSession - Options for resolution or a session * @returns The bound value or a promise of the bound value, depending * on how the binding is configured. * @internal */ getValueOrPromise(keyWithPath, optionsOrSession) { const { key, propertyPath } = BindingKey.parseKeyWithPath(keyWithPath); const options = asResolutionOptions(optionsOrSession); const binding = this.getBinding(key, { optional: true }); if (binding == null) { if (options.optional) return void 0; throw new ResolutionError(`The key '${key}' is not bound to any value in context ${this.name}`, { context: this, binding: Binding.bind(key), options }); } const boundValue = binding.getValue(this, options); return propertyPath == null || propertyPath === "" ? boundValue : transformValueOrPromise(boundValue, (v) => getDeepProperty(v, propertyPath)); } /** * Create a plain JSON object for the context */ toJSON() { const bindings = {}; for (const [k, v] of this.registry) { bindings[k] = v.toJSON(); } return bindings; } /** * Inspect the context and dump out a JSON object representing the context * hierarchy * @param options - Options for inspect */ // TODO(rfeng): Evaluate https://nodejs.org/api/util.html#util_custom_inspection_functions_on_objects inspect(options = {}) { return this._inspect(options, new ClassNameMap()); } /** * Inspect the context hierarchy * @param options - Options for inspect * @param visitedClasses - A map to keep class to name so that we can have * different names for classes with colliding names. The situation can happen * when two classes with the same name are bound in different modules. */ _inspect(options, visitedClasses) { options = { includeParent: true, includeInjections: false, ...options }; const bindings = {}; for (const [k, v] of this.registry) { const ctor = v.valueConstructor ?? v.providerConstructor; let name; if (ctor != null) { name = visitedClasses.visit(ctor); } bindings[k] = v.inspect(options); if (name != null) { const binding = bindings[k]; if (v.valueConstructor) { binding.valueConstructor = name; } else if (v.providerConstructor) { binding.providerConstructor = name; } } } const json = { name: this.name, bindings }; if (!options.includeParent) return json; if (this._parent) { json.parent = this._parent._inspect(options, visitedClasses); } return json; } // 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); } } let ClassNameMap = class ClassNameMap2 { static { __name(this, "ClassNameMap"); } classes = /* @__PURE__ */ new Map(); nameIndex = /* @__PURE__ */ new Map(); visit(ctor) { let name = this.classes.get(ctor); if (name == null) { name = ctor.name; let index = this.nameIndex.get(name); if (typeof index === "number") { this.nameIndex.set(name, ++index); name = `${name} #${index}`; } else { this.nameIndex.set(name, 0); } this.classes.set(ctor, name); } return name; } }; var BindingCreationPolicy = /* @__PURE__ */ function(BindingCreationPolicy2) { BindingCreationPolicy2["ALWAYS_CREATE"] = "Always"; BindingCreationPolicy2["NEVER_CREATE"] = "Never"; BindingCreationPolicy2["CREATE_IF_NOT_BOUND"] = "IfNotBound"; return BindingCreationPolicy2; }({}); export { BindingCreationPolicy, Context }; //# sourceMappingURL=context.js.map