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