Skip to content

Commit

Permalink
Merge fd3464d into 91a37dc
Browse files Browse the repository at this point in the history
  • Loading branch information
raymondfeng committed Feb 1, 2019
2 parents 91a37dc + fd3464d commit f73fe65
Show file tree
Hide file tree
Showing 5 changed files with 540 additions and 10 deletions.
1 change: 1 addition & 0 deletions packages/context/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"dependencies": {
"@loopback/metadata": "^1.0.5",
"debug": "^4.0.1",
"p-event": "^2.2.0",
"uuid": "^3.2.1"
},
"devDependencies": {
Expand Down
52 changes: 52 additions & 0 deletions packages/context/src/context-observer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// Copyright IBM Corp. 2018. All Rights Reserved.
// Node module: @loopback/context
// This file is licensed under the MIT License.
// License text available at https://opensource.org/licenses/MIT

import {Binding} from './binding';
import {BindingFilter} from './binding-filter';
import {ValueOrPromise} from './value-promise';
import {Context} from './context';

/**
* Context event types. We support `bind` and `unbind` for now but
* keep it open for new types
*/
export type ContextEventType = 'bind' | 'unbind' | string;

/**
* Observers of context bind/unbind events
*/
export interface ContextObserver {
/**
* An optional filter function to match bindings. If not present, the listener
* will be notified of all binding events.
*/
filter?: BindingFilter;

/**
* Listen on `bind`, `unbind`, or other events
* @param eventType Context event type
* @param binding The binding as event source
*/
observe(
eventType: ContextEventType,
binding: Readonly<Binding<unknown>>,
context: Context,
): ValueOrPromise<void>;
}

/**
* Subscription of context events. It's modeled after
* https://github.com/tc39/proposal-observable.
*/
export interface Subscription {
/**
* unsubscribe
*/
unsubscribe(): void;
/**
* Is the subscription closed?
*/
closed: boolean;
}
202 changes: 196 additions & 6 deletions packages/context/src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,29 @@
// License text available at https://opensource.org/licenses/MIT

import * as debugModule from 'debug';
import {EventEmitter} from 'events';
import {v1 as uuidv1} from 'uuid';
import {ValueOrPromise} from '.';
import {Binding, BindingTag} from './binding';
import {BindingFilter, filterByKey, filterByTag} from './binding-filter';
import {BindingAddress, BindingKey} from './binding-key';
import {
ContextEventType,
ContextObserver,
Subscription,
} from './context-observer';
import {ResolutionOptions, ResolutionSession} from './resolution-session';
import {BoundValue, getDeepProperty, isPromiseLike} from './value-promise';
import {BindingFilter, filterByKey, filterByTag} from './binding-filter';

// FIXME: `@types/p-event` is out of date against `p-event@2.2.0`
const pEvent = require('p-event');

const debug = debugModule('loopback:context');

/**
* Context provides an implementation of Inversion of Control (IoC) container
*/
export class Context {
export class Context extends EventEmitter {
/**
* Name of the context
*/
Expand All @@ -34,16 +43,109 @@ export class Context {
protected _parent?: Context;

/**
* Create a new context
* A list of registered context observers
*/
protected readonly observers: Set<ContextObserver> = new Set();

/**
* Internal counter for pending events which observers have not processed yet
*/
private pendingEvents = 0;

/**
* Create a new context. For 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 `uuid` will be
* generated as the name
*/
constructor(_parent?: Context | string, name?: string) {
super();
if (typeof _parent === 'string') {
name = _parent;
_parent = undefined;
}
this._parent = _parent;
this.name = name || uuidv1();

this.setupEventHandlers();
}

/**
* Set up an internal listener to notify registered observers asynchronously
* upon `bind` and `unbind` events
*/
private setupEventHandlers() {
// The following are two async functions. Returned promises are ignored as
// they are long-running background tasks.
this.startNotificationTask('bind').catch(err => {
// Catch error to avoid lint violations
this.emit('error', err);
});
this.startNotificationTask('unbind').catch(err => {
// Catch error to avoid lint violations
this.emit('error', err);
});
}

/**
* Start a background task to listen on context events and notify observers
* @param eventType Context event type
*/
private async startNotificationTask(eventType: ContextEventType) {
let currentObservers = this.observers;
this.on(eventType, () => {
// Track pending events
this.pendingEvents++;
// Take a snapshot of current observers to ensure notifications of this
// event will only be sent to current ones
currentObservers = new Set(this.observers);
});
// FIXME(rfeng): p-event should allow multiple event types in an iterator.
// Create an async iterator from the given event type
const bindings: AsyncIterable<Readonly<Binding<unknown>>> = pEvent.iterator(
this,
eventType,
);
for await (const binding of bindings) {
// The loop will happen asynchronously upon events
try {
// The execution of observers happen in the Promise micro-task queue
await this.notifyObservers(eventType, binding, currentObservers);
this.pendingEvents--;
this.emit('observersNotified');
} catch (err) {
this.pendingEvents--;
// Errors caught from observers. Emit it to the current context.
// If no error listeners are registered, crash the process.
this.emit('error', err);
}
}
}

/**
* Wait until observers are notified for all of currently pending events.
*
* This method is for test only to perform assertions after observers are
* notified for relevant events.
*/
protected async waitForObserversNotifiedForPendingEvents() {
const count = this.pendingEvents;
if (count === 0) return;
await pEvent.multiple(this, 'observersNotified', {count});
}

/**
Expand Down Expand Up @@ -72,14 +174,21 @@ export class Context {
debug('Adding binding: %s', key);
}

let existingBinding: Binding | undefined;
const keyExists = this.registry.has(key);
if (keyExists) {
const existingBinding = this.registry.get(key);
existingBinding = this.registry.get(key);
const bindingIsLocked = existingBinding && 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.emit('unbind', existingBinding);
}
this.emit('bind', binding);
}
return this;
}

Expand All @@ -96,10 +205,70 @@ export class Context {
unbind(key: BindingAddress): boolean {
key = BindingKey.validate(key);
const binding = this.registry.get(key);
// If not found, return `false`
if (binding == null) return false;
if (binding && binding.isLocked)
throw new Error(`Cannot unbind key "${key}" of a locked binding`);
return this.registry.delete(key);
this.registry.delete(key);
this.emit('unbind', binding);
return true;
}

/**
* Add a context event observer to the context chain, including its ancestors
* @param observer Context event observer
*/
subscribe(observer: ContextObserver): Subscription {
let ctx: Context | undefined = this;
while (ctx != null) {
ctx.observers.add(observer);
ctx = ctx._parent;
}
return new ContextSubscription(this, observer);
}

/**
* Remove the context event observer from the context chain
* @param observer Context event observer
*/
unsubscribe(observer: ContextObserver) {
let ctx: Context | undefined = this;
while (ctx != null) {
ctx.observers.delete(observer);
ctx = ctx._parent;
}
}

/**
* Check if an observer is subscribed to this context
* @param observer Context observer
*/
isSubscribed(observer: ContextObserver) {
return this.observers.has(observer);
}

/**
* Publish an event to the registered observers. Please note the
* notification is queued and performed asynchronously so that we allow fluent
* APIs such as `ctx.bind('key').to(...).tag(...);` and give observers the
* fully populated binding.
*
* @param eventType Event names: `bind` or `unbind`
* @param binding Binding bound or unbound
* @param observers Current set of context observers
*/
protected async notifyObservers(
eventType: ContextEventType,
binding: Readonly<Binding<unknown>>,
observers = this.observers,
) {
if (observers.size === 0) return;

for (const observer of observers) {
if (!observer.filter || observer.filter(binding)) {
await observer.observe(eventType, binding, this);
}
}
}

/**
Expand Down Expand Up @@ -137,7 +306,7 @@ export class Context {
}

/**
* Find bindings using the key pattern
* 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.
Expand Down Expand Up @@ -451,3 +620,24 @@ export class Context {
return json;
}
}

/**
* An implementation of `Subscription` interface for context events
*/
class ContextSubscription implements Subscription {
constructor(
protected context: Context,
protected observer: ContextObserver,
) {}

private _closed = false;

unsubscribe() {
this.context.unsubscribe(this.observer);
this._closed = true;
}

get closed() {
return this._closed;
}
}
1 change: 1 addition & 0 deletions packages/context/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export * from './binding-inspector';
export * from './binding-key';
export * from './binding-filter';
export * from './context';
export * from './context-observer';
export * from './inject';
export * from './keys';
export * from './provider';
Expand Down

0 comments on commit f73fe65

Please sign in to comment.