/
lifecycleEvents.ts
233 lines (218 loc) · 9.37 KB
/
lifecycleEvents.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
/*
* Copyright (c) 2020, salesforce.com, inc.
* All rights reserved.
* Licensed under the BSD 3-Clause license.
* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
*/
import { AnyJson, Dictionary } from '@salesforce/ts-types';
import { compare } from 'semver';
// needed for TS to not put everything inside /lib/src
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
import * as pjson from '../package.json';
import { Logger } from './logger/logger';
// Data of any type can be passed to the callback. Can be cast to any type that is given in emit().
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type callback = (data: any) => Promise<void>;
declare const global: {
salesforceCoreLifecycle?: Lifecycle;
};
/**
* An asynchronous event listener and emitter that follows the singleton pattern. The singleton pattern allows lifecycle
* events to be emitted from deep within a library and still be consumed by any other library or tool. It allows other
* developers to react to certain situations or events in your library without them having to manually call the method themselves.
*
* An example might be transforming metadata before it is deployed to an environment. As long as an event was emitted from the
* deploy library and you were listening on that event in the same process, you could transform the metadata before the deploy
* regardless of where in the code that metadata was initiated.
*
* @example
* ```
* // Listen for an event in a plugin hook
* Lifecycle.getInstance().on('deploy-metadata', transformMetadata)
*
* // Deep in the deploy code, fire the event for all libraries and plugins to hear.
* Lifecycle.getInstance().emit('deploy-metadata', metadataToBeDeployed);
*
* // if you don't need to await anything
* use `void Lifecycle.getInstance().emit('deploy-metadata', metadataToBeDeployed)` ;
* ```
*/
export class Lifecycle {
public static readonly telemetryEventName = 'telemetry';
public static readonly warningEventName = 'warning';
private logger?: Logger;
private constructor(
private readonly listeners: Dictionary<callback[]> = {},
private readonly uniqueListeners: Map<string, Map<string, callback>> = new Map<string, Map<string, callback>>()
) {}
/**
* return the package.json version of the sfdx-core library.
*/
public static staticVersion(): string {
return pjson.version;
}
/**
* Retrieve the singleton instance of this class so that all listeners and emitters can interact from any library or tool
*/
public static getInstance(): Lifecycle {
// Across a npm dependency tree, there may be a LOT of versions of `@salesforce/core`. We want to ensure that consumers are notified when
// listening on a lifecycle event that is fired by a different version of `@salesforce/core`. Adding the instance on the global object will
// ensure this.
//
// For example, a consumer calls `Lifecycle.getInstance().on('myEvent', ...)` on version `@salesforce/core@2.12.2`, and another consumer calls
// `Lifecycle.getInstance().emit('myEvent', ...)` on version `@salesforce/core@2.13.0`, the on handler will never be called.
//
// Note: If ANYTHING is ever added to this class, it needs to check and update `global.salesforceCoreLifecycle` to the newer version.
// One way this can be done by adding a `version = require(../package.json).version` to the Lifecycle class, then checking if
// `global.salesforceCoreLifecycle` is greater or equal to that version.
//
// For example, let's say a new method is added in `@salesforce/core@3.0.0`. If `Lifecycle.getInstance()` is called fist by
// `@salesforce/core@2.12.2` then by someone who depends on version `@salesforce/core@3.0.0` (who depends on the new method)
// they will get a "method does not exist on object" error because the instance on the global object will be of `@salesforce/core@2.12.2`.
//
// Nothing should EVER be removed, even across major versions.
if (!global.salesforceCoreLifecycle) {
// it's not been loaded yet (basic singleton pattern)
global.salesforceCoreLifecycle = new Lifecycle();
} else if (
// an older version was loaded that should be replaced
compare(global.salesforceCoreLifecycle.version(), Lifecycle.staticVersion()) === -1
) {
const oldInstance = global.salesforceCoreLifecycle;
// use the newer version and transfer any listeners from the old version
// object spread keeps them from being references
global.salesforceCoreLifecycle = new Lifecycle({ ...oldInstance.listeners }, oldInstance.uniqueListeners);
// clean up any listeners on the old version
Object.keys(oldInstance.listeners).map((eventName) => {
oldInstance.removeAllListeners(eventName);
});
}
return global.salesforceCoreLifecycle;
}
/**
* return the package.json version of the sfdx-core library.
*/
// eslint-disable-next-line class-methods-use-this
public version(): string {
return pjson.version;
}
/**
* Remove all listeners for a given event
*
* @param eventName The name of the event to remove listeners of
*/
public removeAllListeners(eventName: string): void {
this.listeners[eventName] = [];
this.uniqueListeners.delete(eventName);
}
/**
* Get an array of listeners (callback functions) for a given event
*
* @param eventName The name of the event to get listeners of
*/
public getListeners(eventName: string): callback[] {
const listeners = this.listeners[eventName]?.concat(
Array.from((this.uniqueListeners.get(eventName) ?? []).values()) ?? []
);
if (listeners) {
return listeners;
} else {
this.listeners[eventName] = [];
return [];
}
}
/**
* Create a listener for the `telemetry` event
*
* @param cb The callback function to run when the event is emitted
*/
public onTelemetry(cb: (data: Record<string, unknown>) => Promise<void>): void {
this.on(Lifecycle.telemetryEventName, cb);
}
/**
* Create a listener for the `warning` event
*
* @param cb The callback function to run when the event is emitted
*/
public onWarning(cb: (warning: string) => Promise<void>): void {
this.on(Lifecycle.warningEventName, cb);
}
/**
* Create a new listener for a given event
*
* @param eventName The name of the event that is being listened for
* @param cb The callback function to run when the event is emitted
* @param uniqueListenerIdentifier A unique identifier for the listener. If a listener with the same identifier is already registered, a new one will not be added
*/
public on<T = AnyJson>(eventName: string, cb: (data: T) => Promise<void>, uniqueListenerIdentifier?: string): void {
const listeners = this.getListeners(eventName);
if (listeners.length !== 0) {
if (!this.logger) {
this.logger = Logger.childFromRoot('Lifecycle');
}
this.logger.debug(
`${
listeners.length + 1
} lifecycle events with the name ${eventName} have now been registered. When this event is emitted all ${
listeners.length + 1
} listeners will fire.`
);
}
if (uniqueListenerIdentifier) {
if (!this.uniqueListeners.has(eventName)) {
// nobody is listening to the event yet
this.uniqueListeners.set(eventName, new Map<string, callback>([[uniqueListenerIdentifier, cb]]));
} else if (!this.uniqueListeners.get(eventName)?.has(uniqueListenerIdentifier)) {
// the unique listener identifier is not already registered
this.uniqueListeners.get(eventName)?.set(uniqueListenerIdentifier, cb);
}
} else {
listeners.push(cb);
this.listeners[eventName] = listeners;
}
}
/**
* Emit a `telemetry` event, causing all callback functions to be run in the order they were registered
*
* @param data The data to emit
*/
public async emitTelemetry(data: AnyJson): Promise<void> {
return this.emit(Lifecycle.telemetryEventName, data);
}
/**
* Emit a `warning` event, causing all callback functions to be run in the order they were registered
*
* @param data The warning (string) to emit
*/
public async emitWarning(warning: string): Promise<void> {
// if there are no listeners, warnings should go to the node process so they're not lost
// this also preserves behavior in UT where there's a spy on process.emitWarning
if (this.getListeners(Lifecycle.warningEventName).length === 0) {
process.emitWarning(warning);
}
return this.emit(Lifecycle.warningEventName, warning);
}
/**
* Emit a given event, causing all callback functions to be run in the order they were registered
*
* @param eventName The name of the event to emit
* @param data The argument to be passed to the callback function
*/
public async emit<T = AnyJson>(eventName: string, data: T): Promise<void> {
const listeners = this.getListeners(eventName);
if (listeners.length === 0 && eventName !== Lifecycle.warningEventName) {
if (!this.logger) {
this.logger = Logger.childFromRoot('Lifecycle');
}
this.logger.debug(
`A lifecycle event with the name ${eventName} does not exist. An event must be registered before it can be emitted.`
);
} else {
for (const cb of listeners) {
// eslint-disable-next-line no-await-in-loop
await cb(data);
}
}
}
}