open-collaboration-protocol
Version:
Open Collaboration Protocol implementation, part of the Open Collaboration Tools project
338 lines (292 loc) • 12.1 kB
text/typescript
// ******************************************************************************
// Copyright 2024 TypeFox GmbH
// This program and the accompanying materials are made available under the
// terms of the MIT License, which is available in the project root.
// ******************************************************************************
import { Disposable, DisposableGroup, DisposableCollection } from './disposable.js';
import { MaybePromise } from './promise.js';
/**
* Represents a typed event.
*/
export interface Event<T> {
/**
*
* @param listener The listener function will be call when the event happens.
* @param thisArgs The 'this' which will be used when calling the event listener.
* @param disposables An array to which a {{IDisposable}} will be added.
* @return a disposable to remove the listener again.
*/
(listener: (e: T) => any, thisArgs?: any, disposables?: Disposable[]): Disposable;
}
export namespace Event {
const _disposable = { dispose(): void { } };
export function getMaxListeners(event: Event<unknown>): number {
const { maxListeners } = event as any;
return typeof maxListeners === 'number' ? maxListeners : 0;
}
export function setMaxListeners<N extends number>(event: Event<unknown>, maxListeners: N): N {
if (typeof (event as any).maxListeners === 'number') {
return (event as any).maxListeners = maxListeners;
}
return maxListeners;
}
export function addMaxListeners(event: Event<unknown>, add: number): number {
if (typeof (event as any).maxListeners === 'number') {
return (event as any).maxListeners += add;
}
return add;
}
export const None: Event<any> = Object.assign(function(): { dispose(): void } { return _disposable; }, {
get maxListeners(): number { return 0; },
set maxListeners(maxListeners: number) { }
});
/**
* Given an event, returns another event which only fires once.
*/
export function once<T>(event: Event<T>): Event<T> {
return (listener, thisArgs = undefined, disposables?) => {
// we need this, in case the event fires during the listener call
let didFire = false;
let result: Disposable | undefined = undefined;
result = event(e => {
if (didFire) {
return;
} else if (result) {
result.dispose();
} else {
didFire = true;
}
return listener.call(thisArgs, e);
}, undefined, disposables);
if (didFire) {
result.dispose();
}
return result;
};
}
export function toPromise<T>(event: Event<T>): Promise<T> {
return new Promise(resolve => once(event)(resolve));
}
/**
* Given an event and a `map` function, returns another event which maps each element
* through the mapping function.
*/
export function map<I, O>(event: Event<I>, mapFunc: (i: I) => O): Event<O> {
return Object.assign((listener: (e: O) => any, thisArgs?: any, disposables?: Disposable[]) => event(i => listener.call(thisArgs, mapFunc(i)), undefined, disposables), {
get maxListeners(): number { return 0; },
set maxListeners(maxListeners: number) { }
});
}
/**
* Given a collection of events, returns a single event which emits whenever any of the provided events emit.
*/
export function any<T>(...events: Array<Event<T>>): Event<T>;
export function any(...events: Array<Event<any>>): Event<void>;
export function any<T>(...events: Array<Event<T>>): Event<T> {
return (listener, thisArgs = undefined, disposables?: Disposable[]) =>
new DisposableCollection(...events.map(event => event(e => listener.call(thisArgs, e), undefined, disposables)));
}
}
type Callback<T> = (...args: Array<(e: T) => any>) => any;
class CallbackList<T> implements Iterable<Callback<T>> {
private _callbacks: Array<(...args: any[]) => any> | undefined;
private _contexts: any[] | undefined;
get length(): number {
return this._callbacks && this._callbacks.length || 0;
}
public add(callback: (...args: any[]) => any, context: any = undefined, bucket?: Disposable[]): void {
if (!this._callbacks) {
this._callbacks = [];
this._contexts = [];
}
this._callbacks.push(callback);
this._contexts!.push(context);
if (Array.isArray(bucket)) {
bucket.push({ dispose: () => this.remove(callback, context) });
}
}
public remove(callback: (...args: any[]) => any, context: any = undefined): void {
if (!this._callbacks) {
return;
}
let foundCallbackWithDifferentContext = false;
for (let i = 0; i < this._callbacks.length; i++) {
if (this._callbacks[i] === callback) {
if (this._contexts![i] === context) {
// callback & context match => remove it
this._callbacks.splice(i, 1);
this._contexts!.splice(i, 1);
return;
} else {
foundCallbackWithDifferentContext = true;
}
}
}
if (foundCallbackWithDifferentContext) {
throw new Error('When adding a listener with a context, you should remove it with the same context');
}
}
// tslint:disable-next-line:typedef
public [Symbol.iterator]() {
if (!this._callbacks) {
return [][Symbol.iterator]();
}
const callbacks = this._callbacks.slice(0);
const contexts = this._contexts!.slice(0);
return callbacks.map((callback, i) =>
(...args: any[]) => callback.apply(contexts[i], args)
)[Symbol.iterator]();
}
public invoke(...args: any[]): any[] {
const ret: any[] = [];
for (const callback of this) {
try {
ret.push(callback(...args));
} catch (e) {
console.error(e);
}
}
return ret;
}
public isEmpty(): boolean {
return !this._callbacks || this._callbacks.length === 0;
}
public dispose(): void {
this._callbacks = undefined;
this._contexts = undefined;
}
}
export interface EmitterOptions {
onFirstListenerAdd?: (emitter: Emitter<any>) => void;
onLastListenerRemove?: (emitter: Emitter<any>) => void;
}
export class Emitter<T = any> {
private static LEAK_WARNING_THRESHHOLD = 175;
private static _noop = function(): void { };
private _event: Event<T>;
protected _callbacks: CallbackList<T> | undefined;
private _disposed = false;
private _leakingStacks: Map<string, number> | undefined;
private _leakWarnCountdown = 0;
constructor(
private _options?: EmitterOptions
) { }
/**
* For the public to allow to subscribe
* to events from this Emitter
*/
get event(): Event<T> {
if (!this._event) {
this._event = Object.assign((listener: (e: T) => any, thisArgs?: any, disposables?: DisposableGroup) => {
if (!this._callbacks) {
this._callbacks = new CallbackList();
}
if (this._options && this._options.onFirstListenerAdd && this._callbacks.isEmpty()) {
this._options.onFirstListenerAdd(this);
}
this._callbacks.add(listener, thisArgs);
const removeMaxListenersCheck = this.checkMaxListeners(Event.getMaxListeners(this._event));
const result: Disposable = {
dispose: () => {
if (removeMaxListenersCheck) {
removeMaxListenersCheck();
}
result.dispose = Emitter._noop;
if (!this._disposed) {
this._callbacks!.remove(listener, thisArgs);
result.dispose = Emitter._noop;
if (this._options && this._options.onLastListenerRemove && this._callbacks!.isEmpty()) {
this._options.onLastListenerRemove(this);
}
}
}
};
if (DisposableGroup.canPush(disposables)) {
disposables.push(result);
} else if (DisposableGroup.canAdd(disposables)) {
disposables.add(result);
}
return result;
}, {
maxListeners: Emitter.LEAK_WARNING_THRESHHOLD
});
}
return this._event;
}
protected checkMaxListeners(maxListeners: number): (() => void) | undefined {
if (maxListeners === 0 || !this._callbacks) {
return undefined;
}
const listenerCount = this._callbacks.length;
if (listenerCount <= maxListeners) {
return undefined;
}
const popStack = this.pushLeakingStack();
this._leakWarnCountdown -= 1;
if (this._leakWarnCountdown <= 0) {
// only warn on first exceed and then every time the limit
// is exceeded by 50% again
this._leakWarnCountdown = maxListeners * 0.5;
let topStack: string;
let topCount = 0;
this._leakingStacks!.forEach((stackCount, stack) => {
if (!topStack || topCount < stackCount) {
topStack = stack;
topCount = stackCount;
}
});
console.warn(`Possible Emitter memory leak detected. ${listenerCount} listeners added. Use event.maxListeners to increase the limit (${maxListeners}). MOST frequent listener (${topCount}):`);
console.warn(topStack!);
}
return popStack;
}
protected pushLeakingStack(): () => void {
if (!this._leakingStacks) {
this._leakingStacks = new Map();
}
const stack = new Error().stack!.split('\n').slice(3).join('\n');
const count = (this._leakingStacks.get(stack) || 0);
this._leakingStacks.set(stack, count + 1);
return () => this.popLeakingStack(stack);
}
protected popLeakingStack(stack: string): void {
if (!this._leakingStacks) {
return;
}
const count = (this._leakingStacks.get(stack) || 0);
this._leakingStacks.set(stack, count - 1);
}
/**
* To be kept private to fire an event to
* subscribers
*/
fire(event: T): any {
if (this._callbacks) {
return this._callbacks.invoke(event);
}
}
/**
* Process each listener one by one.
* Return `false` to stop iterating over the listeners, `true` to continue.
*/
async sequence(processor: (listener: (e: T) => any) => MaybePromise<boolean>): Promise<void> {
if (this._callbacks) {
for (const listener of this._callbacks) {
if (!await processor(listener)) {
break;
}
}
}
}
dispose(): void {
if (this._leakingStacks) {
this._leakingStacks.clear();
this._leakingStacks = undefined;
}
if (this._callbacks) {
this._callbacks.dispose();
this._callbacks = undefined;
}
this._disposed = true;
}
}