UNPKG

@loopback/context

Version:

Facilities to manage artifacts and their dependencies in your Node.js applications. The module exposes TypeScript/JavaScript APIs and decorators to register artifacts, declare dependencies, and resolve artifacts by keys. It also serves as an IoC container

717 lines 26.6 kB
"use strict"; // Copyright IBM Corp. and LoopBack contributors 2017,2020. All Rights Reserved. // Node module: @loopback/context // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT Object.defineProperty(exports, "__esModule", { value: true }); exports.BindingCreationPolicy = exports.Context = void 0; const tslib_1 = require("tslib"); const debug_1 = tslib_1.__importDefault(require("debug")); const events_1 = require("events"); const binding_1 = require("./binding"); const binding_config_1 = require("./binding-config"); const binding_filter_1 = require("./binding-filter"); const binding_key_1 = require("./binding-key"); const context_subscription_1 = require("./context-subscription"); const context_tag_indexer_1 = require("./context-tag-indexer"); const context_view_1 = require("./context-view"); const keys_1 = require("./keys"); const resolution_session_1 = require("./resolution-session"); const unique_id_1 = require("./unique-id"); const value_promise_1 = require("./value-promise"); /** * Context provides an implementation of Inversion of Control (IoC) container */ class Context extends events_1.EventEmitter { /** * 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(); /** * Key to binding map as the internal registry */ this.registry = new Map(); /** * Scope for binding resolution */ this.scope = binding_1.BindingScope.CONTEXT; // The number of listeners can grow with the number of child contexts // For example, each request can add a listener to the RestServer and the // listener is removed when the request processing is finished. // See https://github.com/loopbackio/loopback-next/issues/4363 this.setMaxListeners(Infinity); if (typeof _parent === 'string') { name = _parent; _parent = undefined; } this._parent = _parent; this.name = name !== null && name !== void 0 ? name : this.generateName(); this.tagIndexer = new context_tag_indexer_1.ContextTagIndexer(this); this.subscriptionManager = new context_subscription_1.ContextSubscriptionManager(this); this._debug = (0, debug_1.default)(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 'loopback:context:application'; * } * ``` */ getDebugNamespace() { if (this.constructor === Context) return 'loopback:context'; const name = this.constructor.name.toLowerCase(); return `loopback:context:${name}`; } generateName() { const id = (0, unique_id_1.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) { /* istanbul ignore if */ 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_1.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 === null || existingBinding === void 0 ? void 0 : 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_1.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) { // First try the bound ConfigurationResolver to this context const configResolver = this.getSync(keys_1.ContextBindings.CONFIGURATION_RESOLVER, { optional: true, }); if (configResolver) { this.debug('Custom ConfigurationResolver is loaded from %s.', keys_1.ContextBindings.CONFIGURATION_RESOLVER.toString()); this.configResolver = configResolver; } else { // Fallback to DefaultConfigurationResolver this.debug('DefaultConfigurationResolver is used.'); this.configResolver = new binding_config_1.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 ((0, value_promise_1.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 = binding_key_1.BindingKey.validate(key); const binding = this.registry.get(key); // If not found, return `false` if (binding == null) return false; if (binding === null || binding === void 0 ? void 0 : 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 context_view_1.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 = binding_key_1.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_1.Binding) { key = keyOrBinding.key; } else { key = keyOrBinding; } if (this.contains(key)) { if (keyOrBinding instanceof binding_1.Binding) { // Check if the contained binding is the same if (this.registry.get(key.toString()) === keyOrBinding) { return this; } return undefined; } return this; } if (this._parent) { return this._parent.getOwnerContext(key); } return undefined; } /** * 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 undefined; } /** * 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 binding_1.BindingScope.SINGLETON: // Use the owner context return this.getOwnerContext(binding.key); case binding_1.BindingScope.TRANSIENT: case binding_1.BindingScope.CONTEXT: // Use the current context return this; case binding_1.BindingScope.REQUEST: resolutionCtx = this.getScopedContext(binding.scope); if (resolutionCtx != null) { return resolutionCtx; } else { // If no `REQUEST` scope exists in the chain, fall back to the current // context this.debug('No context is found for binding "%s (scope=%s)". Fall back to the current context.', binding.key, binding.scope); return this; } default: // Use the scoped context 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) { var _a; // Optimize if the binding filter is for tags if (typeof pattern === 'function' && (0, binding_filter_1.isBindingTagFilter)(pattern)) { return this._findByTagIndex(pattern.bindingTagPattern); } const bindings = []; const filter = (0, binding_filter_1.filterByKey)(pattern); for (const b of this.registry.values()) { if (filter(b)) bindings.push(b); } const parentBindings = (_a = this._parent) === null || _a === void 0 ? void 0 : _a.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((0, binding_filter_1.filterByTag)(tagFilter)); } /** * Find bindings by tag leveraging indexes * @param tag - Tag name pattern or name/value pairs */ _findByTagIndex(tag) { var _a; const currentBindings = this.tagIndexer.findByTagIndex(tag); const parentBindings = (_a = this._parent) === null || _a === void 0 ? void 0 : _a._findByTagIndex(tag); return this._mergeWithParent(currentBindings, parentBindings); } _mergeWithParent(childList, parentList) { if (!parentList) return childList; const additions = parentList.filter(parentBinding => { // children bindings take precedence 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 ((0, value_promise_1.isPromiseLike)(valueOrPromise)) { throw new Error(`Cannot get ${keyWithPath} synchronously: the value is a promise`); } return valueOrPromise; } getBinding(key, options) { key = binding_key_1.BindingKey.validate(key); const binding = this.registry.get(key); if (binding) { return binding; } if (this._parent) { return this._parent.getBinding(key, options); } if (options === null || options === void 0 ? void 0 : options.optional) return undefined; 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 === BindingCreationPolicy.ALWAYS_CREATE) { binding = this.bind(key); } else if (policy === BindingCreationPolicy.NEVER_CREATE) { binding = this.getBinding(key); } else if (this.isBound(key)) { // CREATE_IF_NOT_BOUND - the key is bound binding = this.getBinding(key); } else { // CREATE_IF_NOT_BOUND - the key is not bound 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 } = binding_key_1.BindingKey.parseKeyWithPath(keyWithPath); const options = (0, resolution_session_1.asResolutionOptions)(optionsOrSession); const binding = this.getBinding(key, { optional: true }); if (binding == null) { if (options.optional) return undefined; throw new resolution_session_1.ResolutionError(`The key '${key}' is not bound to any value in context ${this.name}`, { context: this, binding: binding_1.Binding.bind(key), options, }); } const boundValue = binding.getValue(this, options); return propertyPath == null || propertyPath === '' ? boundValue : (0, value_promise_1.transformValueOrPromise)(boundValue, v => (0, value_promise_1.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) { var _a; options = { includeParent: true, includeInjections: false, ...options, }; const bindings = {}; for (const [k, v] of this.registry) { const ctor = (_a = v.valueConstructor) !== null && _a !== void 0 ? _a : v.providerConstructor; let name = undefined; 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); } } exports.Context = Context; /** * An internal utility class to handle class name conflicts */ class ClassNameMap { constructor() { this.classes = new Map(); this.nameIndex = new Map(); } visit(ctor) { let name = this.classes.get(ctor); if (name == null) { name = ctor.name; // Now check if the name collides with another class let index = this.nameIndex.get(name); if (typeof index === 'number') { // A conflict is found, mangle the name as `ClassName #1` this.nameIndex.set(name, ++index); name = `${name} #${index}`; } else { // The name is used for the 1st time this.nameIndex.set(name, 0); } this.classes.set(ctor, name); } return name; } } /** * Policy to control if a binding should be created for the context */ var BindingCreationPolicy; (function (BindingCreationPolicy) { /** * Always create a binding with the key for the context */ BindingCreationPolicy["ALWAYS_CREATE"] = "Always"; /** * Never create a binding for the context. If the key is not bound in the * context, throw an error. */ BindingCreationPolicy["NEVER_CREATE"] = "Never"; /** * Create a binding if the key is not bound in the context. Otherwise, return * the existing binding. */ BindingCreationPolicy["CREATE_IF_NOT_BOUND"] = "IfNotBound"; })(BindingCreationPolicy || (exports.BindingCreationPolicy = BindingCreationPolicy = {})); //# sourceMappingURL=context.js.map