@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
JavaScript
"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