UNPKG

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