Skip to content

Commit

Permalink
Merge ebf85b3 into c601c4c
Browse files Browse the repository at this point in the history
  • Loading branch information
raymondfeng authored Jan 29, 2019
2 parents c601c4c + ebf85b3 commit 8c15c90
Show file tree
Hide file tree
Showing 5 changed files with 366 additions and 9 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",
"queue": "^5.0.0",
"uuid": "^3.2.1"
},
"devDependencies": {
Expand Down
52 changes: 52 additions & 0 deletions packages/context/src/context-listener.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;

/**
* Listeners of context bind/unbind events
*/
export interface ContextEventListener {
/**
* 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
*/
listen(
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;
}
143 changes: 138 additions & 5 deletions packages/context/src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,19 @@
// License text available at https://opensource.org/licenses/MIT

import * as debugModule from 'debug';
import Queue from 'queue';
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 {
ContextEventListener,
ContextEventType,
Subscription,
} from './context-listener';
import {ResolutionOptions, ResolutionSession} from './resolution-session';
import {BoundValue, getDeepProperty, isPromiseLike} from './value-promise';
import {BindingFilter, filterByKey, filterByTag} from './binding-filter';

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

Expand All @@ -34,8 +40,34 @@ export class Context {
protected _parent?: Context;

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

/**
* Queue for context event notifications
*/
protected readonly eventQueue = new Queue({concurrency: 1, autostart: true});

/**
* 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) {
if (typeof _parent === 'string') {
Expand Down Expand Up @@ -72,14 +104,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.notifyListeners('unbind', existingBinding);
}
this.notifyListeners('bind', binding);
}
return this;
}

Expand All @@ -96,10 +135,83 @@ 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.notifyListeners('unbind', binding);
return true;
}

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

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

/**
* Check if a listener is subscribed to this context
* @param listener Context listener
*/
isSubscribed(listener: ContextEventListener) {
return this.listeners.has(listener);
}

/**
* Publish an event to the registered listeners. Please note the
* notification is queued and performed asynchronously so that we allow fluent
* APIs such as `ctx.bind('key').to(...).tag(...);` and give listeners the
* fully populated binding.
*
* @param eventType Event names: `bind` or `unbind`
* @param binding Binding bound or unbound
*/
protected notifyListeners(
eventType: ContextEventType,
binding: Readonly<Binding<unknown>>,
) {
if (this.listeners.size === 0) return;
// Schedule the notification task into the event queue
const task = () => {
return new Promise((resolve, reject) => {
// Run notifications in nextTick so that the binding is fully populated
process.nextTick(async () => {
for (const listener of this.listeners) {
if (!listener.filter || listener.filter(binding)) {
try {
await listener.listen(eventType, binding, this);
} catch (err) {
debug(err, eventType, binding);
reject(err);
return;
}
}
}
resolve();
});
});
};
this.eventQueue.push(task);
}

/**
Expand Down Expand Up @@ -137,7 +249,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 +563,24 @@ export class Context {
return json;
}
}

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

private _closed = false;

unsubscribe() {
this.context.unsubscribe(this.listener);
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-listener';
export * from './inject';
export * from './keys';
export * from './provider';
Expand Down
Loading

0 comments on commit 8c15c90

Please sign in to comment.