UNPKG

14.1 kBPlain TextView Raw
1// Copyright IBM Corp. and LoopBack contributors 2019,2020. All Rights Reserved.
2// Node module: @loopback/context
3// This file is licensed under the MIT License.
4// License text available at https://opensource.org/licenses/MIT
5
6import debugFactory from 'debug';
7import {EventEmitter} from 'events';
8import {promisify} from 'util';
9import {Binding} from './binding';
10import {BindingFilter} from './binding-filter';
11import {BindingComparator} from './binding-sorter';
12import {Context} from './context';
13import {ContextEvent} from './context-event';
14import {ContextEventType, ContextObserver} from './context-observer';
15import {Subscription} from './context-subscription';
16import {Getter} from './inject';
17import {
18 asResolutionOptions,
19 ResolutionOptions,
20 ResolutionOptionsOrSession,
21 ResolutionSession,
22} from './resolution-session';
23import {isPromiseLike, resolveList, ValueOrPromise} from './value-promise';
24const debug = debugFactory('loopback:context:view');
25const nextTick = promisify(process.nextTick);
26
27/**
28 * An event emitted by a `ContextView`
29 */
30export interface ContextViewEvent<T> extends ContextEvent {
31 /**
32 * Optional cached value for an `unbind` event
33 */
34 cachedValue?: T;
35}
36
37/**
38 * `ContextView` provides a view for a given context chain to maintain a live
39 * list of matching bindings and their resolved values within the context
40 * hierarchy.
41 *
42 * This class is the key utility to implement dynamic extensions for extension
43 * points. For example, the RestServer can react to `controller` bindings even
44 * they are added/removed/updated after the application starts.
45 *
46 * `ContextView` is an event emitter that emits the following events:
47 * - 'bind': when a binding is added to the view
48 * - 'unbind': when a binding is removed from the view
49 * - 'close': when the view is closed (stopped observing context events)
50 * - 'refresh': when the view is refreshed as bindings are added/removed
51 * - 'resolve': when the cached values are resolved and updated
52 */
53export class ContextView<T = unknown>
54 extends EventEmitter
55 implements ContextObserver
56{
57 /**
58 * An array of cached bindings that matches the binding filter
59 */
60 protected _cachedBindings: Readonly<Binding<T>>[] | undefined;
61 /**
62 * A map of cached values by binding
63 */
64 protected _cachedValues: Map<Readonly<Binding<T>>, T> | undefined;
65 private _subscription: Subscription | undefined;
66
67 /**
68 * Create a context view
69 * @param context - Context object to watch
70 * @param filter - Binding filter to match bindings of interest
71 * @param comparator - Comparator to sort the matched bindings
72 */
73 constructor(
74 public readonly context: Context,
75 public readonly filter: BindingFilter,
76 public readonly comparator?: BindingComparator,
77 private resolutionOptions?: Omit<ResolutionOptions, 'session'>,
78 ) {
79 super();
80 }
81
82 /**
83 * Update the cached values keyed by binding
84 * @param values - An array of resolved values
85 */
86 private updateCachedValues(values: T[]) {
87 if (this._cachedBindings == null) return undefined;
88 this._cachedValues = new Map();
89 for (let i = 0; i < this._cachedBindings?.length; i++) {
90 this._cachedValues.set(this._cachedBindings[i], values[i]);
91 }
92 return this._cachedValues;
93 }
94
95 /**
96 * Get an array of cached values
97 */
98 private getCachedValues() {
99 return Array.from(this._cachedValues?.values() ?? []);
100 }
101
102 /**
103 * Start listening events from the context
104 */
105 open() {
106 debug('Start listening on changes of context %s', this.context.name);
107 if (this.context.isSubscribed(this)) {
108 return this._subscription;
109 }
110 this._subscription = this.context.subscribe(this);
111 return this._subscription;
112 }
113
114 /**
115 * Stop listening events from the context
116 */
117 close() {
118 debug('Stop listening on changes of context %s', this.context.name);
119 if (!this._subscription || this._subscription.closed) return;
120 this._subscription.unsubscribe();
121 this._subscription = undefined;
122 this.emit('close');
123 }
124
125 /**
126 * Get the list of matched bindings. If they are not cached, it tries to find
127 * them from the context.
128 */
129 get bindings(): Readonly<Binding<T>>[] {
130 debug('Reading bindings');
131 if (this._cachedBindings == null) {
132 this._cachedBindings = this.findBindings();
133 }
134 return this._cachedBindings;
135 }
136
137 /**
138 * Find matching bindings and refresh the cache
139 */
140 protected findBindings(): Readonly<Binding<T>>[] {
141 debug('Finding matching bindings');
142 const found = this.context.find(this.filter);
143 if (typeof this.comparator === 'function') {
144 found.sort(this.comparator);
145 }
146 /* istanbul ignore if */
147 if (debug.enabled) {
148 debug(
149 'Bindings found',
150 found.map(b => b.key),
151 );
152 }
153 return found;
154 }
155
156 /**
157 * Listen on `bind` or `unbind` and invalidate the cache
158 */
159 observe(
160 event: ContextEventType,
161 binding: Readonly<Binding<unknown>>,
162 context: Context,
163 ) {
164 const ctxEvent: ContextViewEvent<T> = {
165 context,
166 binding,
167 type: event,
168 };
169 debug('Observed event %s %s %s', event, binding.key, context.name);
170
171 if (event === 'unbind') {
172 const cachedValue = this._cachedValues?.get(
173 binding as Readonly<Binding<T>>,
174 );
175 this.emit(event, {...ctxEvent, cachedValue});
176 } else {
177 this.emit(event, ctxEvent);
178 }
179
180 this.refresh();
181 }
182
183 /**
184 * Refresh the view by invalidating its cache
185 */
186 refresh() {
187 debug('Refreshing the view by invalidating cache');
188 this._cachedBindings = undefined;
189 this._cachedValues = undefined;
190 this.emit('refresh');
191 }
192
193 /**
194 * Resolve values for the matching bindings
195 * @param session - Resolution session
196 */
197 resolve(session?: ResolutionOptionsOrSession): ValueOrPromise<T[]> {
198 debug('Resolving values');
199 if (this._cachedValues != null) {
200 return this.getCachedValues();
201 }
202 const bindings = this.bindings;
203 let result = resolveList(bindings, b => {
204 const options = {
205 ...this.resolutionOptions,
206 ...asResolutionOptions(session),
207 };
208 // https://github.com/loopbackio/loopback-next/issues/9041
209 // We should start with a new session for `view` resolution to avoid
210 // possible circular dependencies
211 options.session = undefined;
212 return b.getValue(this.context, options);
213 });
214 if (isPromiseLike(result)) {
215 result = result.then(values => {
216 const list = values.filter(v => v != null) as T[];
217 this.updateCachedValues(list);
218 this.emit('resolve', list);
219 return list;
220 });
221 } else {
222 // Clone the array so that the cached values won't be mutated
223 const list = (result = result.filter(v => v != null) as T[]);
224 this.updateCachedValues(list);
225 this.emit('resolve', list);
226 }
227 return result as ValueOrPromise<T[]>;
228 }
229
230 /**
231 * Get the list of resolved values. If they are not cached, it tries to find
232 * and resolve them.
233 */
234 async values(session?: ResolutionOptionsOrSession): Promise<T[]> {
235 debug('Reading values');
236 // Wait for the next tick so that context event notification can be emitted
237 await nextTick();
238 if (this._cachedValues == null) {
239 return this.resolve(session);
240 }
241 return this.getCachedValues();
242 }
243
244 /**
245 * As a `Getter` function
246 */
247 asGetter(session?: ResolutionOptionsOrSession): Getter<T[]> {
248 return () => this.values(session);
249 }
250
251 /**
252 * Get the single value
253 */
254 async singleValue(
255 session?: ResolutionOptionsOrSession,
256 ): Promise<T | undefined> {
257 const values = await this.values(session);
258 if (values.length === 0) return undefined;
259 if (values.length === 1) return values[0];
260 throw new Error(
261 'The ContextView has more than one value. Use values() to access them.',
262 );
263 }
264
265 /**
266 * The "bind" event is emitted when a new binding is added to the view.
267 *
268 * @param eventName The name of the event - always `bind`.
269 * @param listener The listener function to call when the event is emitted.
270 */
271 on(
272 eventName: 'bind',
273 listener: <V>(event: ContextViewEvent<V>) => void,
274 ): this;
275
276 /**
277 * The "unbind" event is emitted a new binding is removed from the view.
278 *
279 * @param eventName The name of the event - always `unbind`.
280 * @param listener The listener function to call when the event is emitted.
281 */
282 on(
283 eventName: 'unbind',
284 listener: <V>(event: ContextViewEvent<V> & {cachedValue?: V}) => void,
285 ): this;
286
287 /**
288 * The "refresh" event is emitted when the view is refreshed as bindings are
289 * added/removed.
290 *
291 * @param eventName The name of the event - always `refresh`.
292 * @param listener The listener function to call when the event is emitted.
293 */
294 on(eventName: 'refresh', listener: () => void): this;
295
296 /**
297 * The "resolve" event is emitted when the cached values are resolved and
298 * updated.
299 *
300 * @param eventName The name of the event - always `refresh`.
301 * @param listener The listener function to call when the event is emitted.
302 */
303 // eslint-disable-next-line @typescript-eslint/unified-signatures
304 on(eventName: 'refresh', listener: <V>(result: V[]) => void): this;
305
306 /**
307 * The "close" event is emitted when the view is closed (stopped observing
308 * context events)
309 *
310 * @param eventName The name of the event - always `close`.
311 * @param listener The listener function to call when the event is emitted.
312 */
313 // eslint-disable-next-line @typescript-eslint/unified-signatures
314 on(eventName: 'close', listener: () => void): this;
315
316 // The generic variant inherited from EventEmitter
317 // eslint-disable-next-line @typescript-eslint/no-explicit-any
318 on(event: string | symbol, listener: (...args: any[]) => void): this;
319
320 // eslint-disable-next-line @typescript-eslint/no-explicit-any
321 on(event: string | symbol, listener: (...args: any[]) => void): this {
322 return super.on(event, listener);
323 }
324
325 /**
326 * The "bind" event is emitted when a new binding is added to the view.
327 *
328 * @param eventName The name of the event - always `bind`.
329 * @param listener The listener function to call when the event is emitted.
330 */
331 once(
332 eventName: 'bind',
333 listener: <V>(event: ContextViewEvent<V>) => void,
334 ): this;
335
336 /**
337 * The "unbind" event is emitted a new binding is removed from the view.
338 *
339 * @param eventName The name of the event - always `unbind`.
340 * @param listener The listener function to call when the event is emitted.
341 */
342 once(
343 eventName: 'unbind',
344 listener: <V>(event: ContextViewEvent<V> & {cachedValue?: V}) => void,
345 ): this;
346
347 /**
348 * The "refresh" event is emitted when the view is refreshed as bindings are
349 * added/removed.
350 *
351 * @param eventName The name of the event - always `refresh`.
352 * @param listener The listener function to call when the event is emitted.
353 */
354 once(eventName: 'refresh', listener: () => void): this;
355
356 /**
357 * The "resolve" event is emitted when the cached values are resolved and
358 * updated.
359 *
360 * @param eventName The name of the event - always `refresh`.
361 * @param listener The listener function to call when the event is emitted.
362 */
363 // eslint-disable-next-line @typescript-eslint/unified-signatures
364 once(eventName: 'refresh', listener: <V>(result: V[]) => void): this;
365
366 /**
367 * The "close" event is emitted when the view is closed (stopped observing
368 * context events)
369 *
370 * @param eventName The name of the event - always `close`.
371 * @param listener The listener function to call when the event is emitted.
372 */
373 // eslint-disable-next-line @typescript-eslint/unified-signatures
374 once(eventName: 'close', listener: () => void): this;
375
376 // The generic variant inherited from EventEmitter
377 // eslint-disable-next-line @typescript-eslint/no-explicit-any
378 once(event: string | symbol, listener: (...args: any[]) => void): this;
379
380 // eslint-disable-next-line @typescript-eslint/no-explicit-any
381 once(event: string | symbol, listener: (...args: any[]) => void): this {
382 return super.once(event, listener);
383 }
384}
385
386/**
387 * Create a context view as a getter with the given filter
388 * @param ctx - Context object
389 * @param bindingFilter - A function to match bindings
390 * @param session - Resolution session
391 */
392export function createViewGetter<T = unknown>(
393 ctx: Context,
394 bindingFilter: BindingFilter,
395 session?: ResolutionSession,
396): Getter<T[]>;
397
398/**
399 * Create a context view as a getter with the given filter and sort matched
400 * bindings by the comparator.
401 * @param ctx - Context object
402 * @param bindingFilter - A function to match bindings
403 * @param bindingComparator - A function to compare two bindings
404 * @param session - Resolution session
405 */
406export function createViewGetter<T = unknown>(
407 ctx: Context,
408 bindingFilter: BindingFilter,
409 bindingComparator?: BindingComparator,
410 session?: ResolutionOptionsOrSession,
411): Getter<T[]>;
412
413/**
414 * Create a context view as a getter
415 * @param ctx - Context object
416 * @param bindingFilter - A function to match bindings
417 * @param bindingComparatorOrSession - A function to sort matched bindings or
418 * resolution session if the comparator is not needed
419 * @param session - Resolution session if the comparator is provided
420 */
421export function createViewGetter<T = unknown>(
422 ctx: Context,
423 bindingFilter: BindingFilter,
424 bindingComparatorOrSession?: BindingComparator | ResolutionSession,
425 session?: ResolutionOptionsOrSession,
426): Getter<T[]> {
427 let bindingComparator: BindingComparator | undefined = undefined;
428 if (typeof bindingComparatorOrSession === 'function') {
429 bindingComparator = bindingComparatorOrSession;
430 } else if (bindingComparatorOrSession instanceof ResolutionSession) {
431 session = bindingComparatorOrSession;
432 }
433
434 const options = asResolutionOptions(session);
435 const view = new ContextView<T>(
436 ctx,
437 bindingFilter,
438 bindingComparator,
439 options,
440 );
441 view.open();
442 return view.asGetter(options);
443}