What is Context?

  • An abstraction of all state and dependencies in your application
  • LoopBack uses context to manage everything
  • A global registry for anything/everything in your app (all configs, state,dependencies, classes, etc)
  • An inversion of controlcontainer used to inject dependencies into your codeContext

Why is it important?

  • You can use the context as a way to give loopback more “info” so that otherdependencies in your app may retrieve it. It works as a centralized place/global built-in/in-memory storage mechanism.
  • LoopBack can help “manage” your resources automatically (throughDependency Injection and decorators).
  • You have full access to updated/real-time application and request state at alltimes.IoC Container

How to create a context?

A context can be created with an optional parent and an optional name. If thename is not provided, a UUID will be generated as the value. Context instancescan be chained using the parent to form a hierarchy. For example, the codebelow creates a chain of three contexts: reqCtx -> serverCtx -> rootCtx.

  1. import {Context} from '@loopback/context';
  2. const rootCtx = new Context('root-ctx'); // No parent
  3. const serverCtx = new Context(rootCtx, 'server-ctx'); // rootCtx as the parent
  4. const reqCtx = new Context(serverCtx); // No explicit name, a UUID will be generated

LoopBack’s context system allows an unlimited amount of Context instances, eachof which may have a parent Context.

An application typically has three “levels” of context: application-level,server-level, and request-level.

Application-level context (global)

  • Stores all the initial and modified app states throughout the entire life ofthe app (while the process is alive)
  • Generally configured when the application is created (though the context maybe modified while running)Here is a simple example:
  1. import {Application} from '@loopback/core';
  2. // Please note `Application` extends from `Context`
  3. const app = new Application(); // `app` is a "Context"
  4. class MyController {}
  5. app.controller(MyController);

In this case, you are using the .controller helper method to register a newcontroller. The important point to note is MyController is actually registeredinto the Application Context (app is a Context).

Server-level context

Server-level context:

  • Is a child of application-level context
  • Holds configuration specific to a particular server instanceYour application will typically contain one or more server instances, each ofwhich will have the application-level context as its parent. This means that anybindings that are defined on the application will also be available to theserver(s), unless you replace these bindings on the server instance(s) directly.

For example,@loopback/resthas the RestServer class, which sets up a running HTTP/S server on a port, aswell as defining routes on that server for a REST API. To set the port bindingfor the RestServer, you would bind the RestBindings.PORT key to a number.

We can selectively re-bind this value for certain server instances to changewhat port they use:

  1. // src/application.ts
  2. async start() {
  3. // publicApi will use port 443, since it inherits this binding from the app.
  4. app.bind(RestBindings.PORT).to(443);
  5. const publicApi = await app.getServer<RestServer>('public');
  6. const privateApi = await app.getServer<RestServer>('private');
  7. // privateApi will be bound to 8080 instead.
  8. privateApi.bind(RestBindings.PORT).to(8080);
  9. await super.start();
  10. }

Request-level context (request)

Using@loopback/restas an example, we can create custom sequences that:

  • are dynamically created for each incoming server request
  • extend the application level context to give you access to application-leveldependencies during the request/response lifecycle
  • are garbage-collected once the response is sent for memory managementLet’s see this in action:
  1. import {DefaultSequence, RestBindings, RequestContext} from '@loopback/rest';
  2. class MySequence extends DefaultSequence {
  3. async handle(context: RequestContext) {
  4. // RequestContext provides request/response properties for convenience
  5. // and performance, but they are still available in the context too
  6. const req = await this.ctx.get(RestBindings.Http.REQUEST);
  7. const res = await this.ctx.get(RestBindings.Http.RESPONSE);
  8. this.send(res, `hello ${req.query.name}`);
  9. }
  10. }
  • this.ctx is available to your sequence
  • allows you to craft your response using resources from the app in addition tothe resources available to the request in real-time (right when you need it)The context hierarchy is illustrated in the diagram below:

Context Hierarchy

Storing and retrieving items from a Context

Items in the Context are indexed via a key and bound to a BoundValue. ABindingKey is simply a string value and is used to look up whatever you storealong with the key. For example:

  1. // app level
  2. const app = new Application();
  3. app.bind('hello').to('world'); // BindingKey='hello', BoundValue='world'
  4. console.log(app.getSync<string>('hello')); // => 'world'

In this case, we bind the ‘world’ string BoundValue to the ‘hello’ BindingKey.When we fetch the BoundValue via getSync, we give it the BindingKey and itreturns the BoundValue that was initially bound (we can do other fancy thingstoo – ie. instantiate your classes, etc)

The process of registering a BoundValue into the Context is known as binding.Please find more details at Binding.

For a list of the available functions you can use for binding, visit theContext API Docs.

Dependency injection

  • Many configs are adding to the Context during app instantiation/boot time byyou/developer.
  • When things are registered, the Context provides a way to use yourdependencies during runtime.How you access these things is via low level helpers like app.getSync or thesequence class that is provided to you as shown in the example in the previoussection.

However, when using classes, LoopBack provides a better way to get at stuff inthe context via the @inject decorator:

  1. import {inject} from '@loopback/context';
  2. import {Application} from '@loopback/core';
  3. const app = new Application();
  4. app.bind('defaultName').to('John');
  5. export class HelloController {
  6. constructor(@inject('defaultName') private name: string) {}
  7. greet(name?: string) {
  8. return `Hello ${name || this.name}`;
  9. }
  10. }

Notice we just use the default name as though it were available to theconstructor. Context allows LoopBack to give you the necessary information atruntime even if you do not know the value when writing up the Controller. Theabove will print Hello John at run time.

Note:

@inject decorator is not able to leverage the value-type informationassociated with a binding key yet, therefore the TypeScript compiler will notcheck that the injection target (e.g. a constructor argument) was declaredwith a type that the bound value can be assigned to.

Please refer to Dependency injection for furtherdetails.

Context metadata and sugar decorators

Other interesting decorators can be used to help give LoopBack hints toadditional metadata you may want to provide in order to automatically set thingsup. For example, let’s take the previous example and make it available on theGET /greet route using decorators provided by@loopback/rest:

  1. class HelloController {
  2. // tell LoopBack you want this controller method
  3. // to be available at the GET /greet route
  4. @get('/greet')
  5. greet(
  6. // tell LoopBack you want to accept
  7. // the name parameter as a string from
  8. // the query string
  9. @param.query.string('name') name: string,
  10. ) {
  11. return `Hello ${name}`;
  12. }
  13. }

These “sugar” decorators allow you to quickly build up your application withouthaving to code up all the additional logic by simply giving LoopBack hints (inthe form of metadata) to your intent.

Context events

The Context emits the following events:

  • bind: Emitted when a new binding is added to the context.
    • binding: the newly added binding object
    • context: Owner context of the binding object
  • unbind: Emitted when an existing binding is removed from the context
    • binding: the newly removed binding object
    • context: Owner context of the binding object
  • error: Emitted when an observer throws an error during the notificationprocess
    • err: the error object thrownWhen an existing binding key is replaced with a new one, an unbind event isemitted for the existing binding followed by a bind event for the new binding.

If a context has a parent, binding events from the parent are re-emitted on thecontext when the binding key does not exist within the current context.

Context observers

Bindings can be added or removed to a context object. With emitted contextevents, we can add listeners to a context object to be invoked when bindingscome and go. There are a few caveats associated with that:

  • The binding object might not be fully configured when a bind event isemitted.

For example:

  1. const ctx = new Context();
  2. ctx
  3. .bind('foo')
  4. .to('foo-value')
  5. .tag('foo-tag');
  6. ctx.on('bind', binding => {
  7. console.log(binding.tagNames); // returns an empty array `[]`
  8. });

The context object emits a bind event when ctx.bind method is called. Itdoes not control the fluent apis .to('foo-value').tag('foo-tag'), whichhappens on the newly created binding object. As a result, the bind eventlistener receives a binding object which only has the binding key populated.

A workaround is to create the binding first before add it to a context:

  1. const ctx = new Context();
  2. const binding = Binding.create('foo')
  3. .to('foo-value')
  4. .tag('foo-tag');
  5. ctx.add(binding);
  6. ctx.on('bind', binding => {
  7. console.log(binding.tagMap); // returns `['foo-tag']`
  8. });
  • It’s hard for event listeners to perform asynchronous operations.

To make it easy to support asynchronous event processing, we introduceContextObserver and corresponding APIs on Context:

  • ContextObserverFn type and ContextObserver interface
  1. /**
  2. * Listen on `bind`, `unbind`, or other events
  3. * @param eventType - Context event type
  4. * @param binding - The binding as event source
  5. * @param context - Context object for the binding event
  6. */
  7. export type ContextObserverFn = (
  8. eventType: ContextEventType,
  9. binding: Readonly<Binding<unknown>>,
  10. context: Context,
  11. ) => ValueOrPromise<void>;
  12. /**
  13. * Observers of context bind/unbind events
  14. */
  15. export interface ContextObserver {
  16. /**
  17. * An optional filter function to match bindings. If not present, the listener
  18. * will be notified of all binding events.
  19. */
  20. filter?: BindingFilter;
  21. /**
  22. * Listen on `bind`, `unbind`, or other events
  23. * @param eventType - Context event type
  24. * @param binding - The binding as event source
  25. */
  26. observe: ContextObserverFn;
  27. }
  28. /**
  29. * Context event observer type - An instance of `ContextObserver` or a function
  30. */
  31. export type ContextEventObserver = ContextObserver | ContextObserverFn;

If filter is not required, we can simply use ContextObserverFn.

  • Context APIs
  • subscribe(observer: ContextEventObserver)

Add a context event observer to the context chain, including its ancestors

  • unsubscribe(observer: ContextEventObserver)

Remove the context event observer from the context chain

  • close()

Close the context and release references to other objects in the contextchain. Please note a child context registers event listeners with its parentcontext. As a result, the close method must be called to avoid memory leakif the child context is to be recycled.

To react on context events asynchronously, we need to implement theContextObserver interface or provide a ContextObserverFn and register itwith the context.

For example:

  1. const app = new Context('app');
  2. server = new Context(app, 'server');
  3. const observer: ContextObserver = {
  4. // Only interested in bindings tagged with `foo`
  5. filter: binding => binding.tagMap.foo != null,
  6. observe(event: ContextEventType, binding: Readonly<Binding<unknown>>) {
  7. if (event === 'bind') {
  8. console.log('bind: %s', binding.key);
  9. // ... perform async operation
  10. } else if (event === 'unbind') {
  11. console.log('unbind: %s', binding.key);
  12. // ... perform async operation
  13. }
  14. },
  15. };
  16. server.subscribe(observer);
  17. server
  18. .bind('foo-server')
  19. .to('foo-value')
  20. .tag('foo');
  21. app
  22. .bind('foo-app')
  23. .to('foo-value')
  24. .tag('foo');
  25. // The following messages will be printed:
  26. // bind: foo-server
  27. // bind: foo-app

Please note when an observer subscribes to a context, it will be registered withall contexts on the chain. In the example above, the observer is added to bothserver and app contexts so that it can be notified when bindings are addedor removed from any of the context on the chain.

  • Observers are called in the next turn ofPromise micro-task queue

  • When there are multiple async observers registered, they are notified inseries for an event.

  • When multiple binding events are emitted in the same event loop tick and thereare async observers registered, such events are queued and observers arenotified by the order of events.

Observer error handling

It’s recommended that ContextEventObserver implementations should not throwerrors in their code. Errors thrown by context event observers are reported asfollows over the context chain.

  • Check if the current context object has error listeners, if yes, emit anerror event on the context and we’re done. if not, try its parent contextby repeating step 1.

  • If no context object of the chain has error listeners, emit an errorevent on the current context. As a result, the process exits abnormally. Seehttps://nodejs.org/api/events.html#events_error_events for more details.

Context view

Bindings in a context can come and go. It’s often desirable for an artifact(especially an extension point) to keep track of other artifacts (extensions).For example, the RestServer needs to know routes contributed by controllerclasses or other handlers. Such routes can be added or removed after theRestServer starts. When a controller is added after the application starts,new routes are bound into the application context. Ideally, the RestServershould be able to pick up these new routes without restarting.

To support the dynamic tracking of such artifacts registered within a contextchain, we introduce ContextObserver interface and ContextView class that canbe used to watch a list of bindings matching certain criteria depicted by aBindingFilter function and an optional BindingComparator function to sortmatched bindings.

  1. import {Context, ContextView} from '@loopback/context';
  2. // Set up a context chain
  3. const appCtx = new Context('app');
  4. const serverCtx = new Context(appCtx, 'server'); // server -> app
  5. // Define a binding filter to select bindings with tag `controller`
  6. const controllerFilter = binding => binding.tagMap.controller != null;
  7. // Watch for bindings with tag `controller`
  8. const view = serverCtx.createView(controllerFilter);
  9. // No controllers yet
  10. await view.values(); // returns []
  11. // Bind Controller1 to server context
  12. serverCtx
  13. .bind('controllers.Controller1')
  14. .toClass(Controller1)
  15. .tag('controller');
  16. // Resolve to an instance of Controller1
  17. await view.values(); // returns [an instance of Controller1];
  18. // Bind Controller2 to app context
  19. appCtx
  20. .bind('controllers.Controller2')
  21. .toClass(Controller2)
  22. .tag('controller');
  23. // Resolve to an instance of Controller1 and an instance of Controller2
  24. await view.values(); // returns [an instance of Controller1, an instance of Controller2];
  25. // Unbind Controller2
  26. appCtx.unbind('controllers.Controller2');
  27. // No more instance of Controller2
  28. await view.values(); // returns [an instance of Controller1];

The key benefit of ContextView is that it caches resolved values until contextbindings matching the filter function are added/removed. For most cases, wedon’t have to pay the penalty to find/resolve per request.

To fully leverage the live list of extensions, an extension point such asRoutingTable should either keep a pointer to an instance of ContextViewcorresponding to all routes (extensions) in the context chain and use thevalues() function to match again the live routes per request or implementitself as a ContextObserver to rebuild the routes upon changes of routes inthe context with listen().

If your dependency needs to follow the context for values from bindings matchinga filter, use @inject.view for dependencyinjection.

ContextView events

A ContextView object can emit one of the following events:

  • ‘refresh’: when the view is refreshed as bindings are added/removed
  • ‘resolve’: when the cached values are resolved and updated
  • ‘close’: when the view is closed (stopped observing context events)Such as events can be used to update other states/cached values other than thevalues watched by the ContextView object itself. For example:
  1. class MyController {
  2. private _total: number | undefined = undefined;
  3. constructor(
  4. @inject.view(filterByTag('counter'))
  5. private taggedAsFoo: ContextView<Counter>,
  6. ) {
  7. // Invalidate cached `_total` if the view is refreshed
  8. taggedAsFoo.on('refresh', () => {
  9. this._total = undefined;
  10. });
  11. }
  12. async total() {
  13. if (this._total != null) return this._total;
  14. // Calculate the total of all counters
  15. const counters = await this.taggedAsFoo.values();
  16. let result = 0;
  17. for (const c of counters) {
  18. result += c.value;
  19. }
  20. this._total = result;
  21. return this._total;
  22. }
  23. }

Configuration by convention

To allow bound items in the context to be configured, we introduce someconventions and corresponding APIs to make it simple and consistent.

We treat configurations for bound items in the context as dependencies, whichcan be resolved and injected in the same way of other forms of dependencies. Forexample, the RestServer can be configured with RestServerConfig.

Let’s first look at an example:

  1. export class RestServer {
  2. constructor(
  3. @inject(CoreBindings.APPLICATION_INSTANCE) app: Application,
  4. @inject(RestBindings.CONFIG, {optional: true})
  5. config: RestServerConfig = {},
  6. ) {
  7. // ...
  8. }
  9. // ...
  10. }

The configuration (RestServerConfig) itself is a binding(RestBindings.CONFIG) in the context. It’s independent of the binding forRestServer. The caveat is that we need to maintain a different binding key forthe configuration. Referencing a hard-coded key for the configuration also makesit impossible to have more than one instances of the RestServer to beconfigured with different options, such as protocol or port.

To solve these problems, we introduce an accompanying binding for an item thatexpects configuration. For example:

  • servers.RestServer.server1: RestServer
  • servers.RestServer.server1:$config: RestServerConfig

  • servers.RestServer.server2: RestServer

  • servers.RestServer.server2:$config: RestServerConfigThe following APIs are available to enforce/leverage this convention:

  • ctx.configure('servers.RestServer.server1') => Binding for theconfiguration

  • Binding.configure('servers.RestServer.server1') => Creates a accompanyingbinding for the configuration of the target binding
  • ctx.getConfig('servers.RestServer.server1') => Get configuration
  • @config to inject corresponding configuration
  • @config.getter to inject a getter function for corresponding configuration
  • @config.view to inject a ContextView for corresponding configurationThe RestServer can now use @config to inject configuration for the currentbinding of RestServer.
  1. export class RestServer {
  2. constructor(
  3. @inject(CoreBindings.APPLICATION_INSTANCE) app: Application,
  4. @config()
  5. config: RestServerConfig = {},
  6. ) {
  7. // ...
  8. }
  9. // ...
  10. }

The @config.* decorators can take an optional propertyPath parameter toallow the configuration value to be a deep property of the bound value. Forexample, @config('port') injects RestServerConfig.port to the target.

  1. export class MyRestServer {
  2. constructor(
  3. @config('host')
  4. host: string,
  5. @config('port')
  6. port: number,
  7. ) {
  8. // ...
  9. }
  10. // ...
  11. }

We also allow @config.* to be resolved from another binding than the currentone:

  1. export class MyRestServer {
  2. constructor(
  3. // Inject the `rest.host` from the application config
  4. @config({fromBinding: 'application', propertyPath: 'rest.host'})
  5. host: string,
  6. // Inject the `rest.port` from the application config
  7. @config({fromBinding: 'application', propertyPath: 'rest.port'})
  8. port: number,
  9. ) {
  10. // ...
  11. }
  12. // ...
  13. }

Now we can use context.configure() to provide configuration for targetbindings.

  1. const appCtx = new Context();
  2. appCtx.bind('servers.RestServer.server1').toClass(RestServer);
  3. appCtx
  4. .configure('servers.RestServer.server1')
  5. .to({protocol: 'https', port: 473});
  6. appCtx.bind('servers.RestServer.server2').toClass(RestServer);
  7. appCtx.configure('servers.RestServer.server2').to({protocol: 'http', port: 80});

Please note that @config. is different from @inject. as @config.injects configuration based on the current binding where @config. is applied.No hard-coded binding key is needed. The @config.* also allows the same classsuch as RestServer to be bound to different keys with different configurationsas illustrated in the code snippet above.

All configuration accessors or injectors (such as ctx.getConfig, @config) bydefault treat the configuration binding as optional, i.e. return undefined ifno configuration was bound. This is different from ctx.get and @inject APIs,which require the binding to exist and throw an error when the requested bindingis not found. The behavior can be customized via ResolutionOptions.optionalflag.

Allow configuration to be changed dynamically

Some configurations are designed to be changeable dynamically, for example, thelogging level for an application. To allow that, we introduce @config.getterto always fetch the latest value of the configuration.

  1. export class Logger {
  2. @config.getter()
  3. private getLevel: Getter<string>;
  4. async log(level: string, message: string) {
  5. const currentLevel = await getLevel();
  6. if (shouldLog(level, currentLevel)) {
  7. // ...
  8. }
  9. }
  10. }