| 1 | // This file is part of HFS - Copyright 2021-2023, Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt |
| 2 | |
| 3 | type Listener = (...args: any[]) => unknown |
| 4 | type Listeners = Set<Listener> |
| 5 | const LISTENERS_SUFFIX = '\0listeners' |
| 6 | |
| 7 | export interface OnOptions { warnAfter?: number, callNow?: boolean } |
| 8 | |
| 9 | export class BetterEventEmitter { |
| 10 | protected listeners = new Map<string, Listeners>() |
| 11 | stop = Symbol() |
| 12 | on(event: string | string[], listener: Listener, { warnAfter=10, callNow=false }: OnOptions={}) { |
| 13 | if (typeof event === 'string') |
| 14 | event = [event] |
| 15 | for (const e of event) { |
| 16 | let cbs = this.listeners.get(e) |
| 17 | if (!cbs) |
| 18 | this.listeners.set(e, cbs = new Set()) |
| 19 | cbs.add(listener) |
| 20 | if (cbs.size > warnAfter) |
| 21 | console.warn("Warning: many event listeners for ", e) |
| 22 | this.emit(e + LISTENERS_SUFFIX, cbs, listener) |
| 23 | } |
| 24 | if (callNow) |
| 25 | try { listener() } |
| 26 | catch {} |
| 27 | return () => { |
| 28 | for (const e of event) { |
| 29 | const cbs = this.listeners.get(e) |
| 30 | if (!cbs) continue |
| 31 | cbs.delete(listener) |
| 32 | this.emit(e + LISTENERS_SUFFIX, cbs) |
| 33 | } |
| 34 | } |
| 35 | } |
| 36 | // call me when listeners for the event have changed |
| 37 | onListeners(event: string, listener: Listener) { |
| 38 | return this.on(event + LISTENERS_SUFFIX, listener) |
| 39 | } |
| 40 | // returns the unsubscriber function, which is also a PromiseLike with the array of arguments received by the listener |
| 41 | once(event: string, listener?: Listener) { |
| 42 | let off: () => unknown |
| 43 | const pro = new Promise<any[]>(resolve => { |
| 44 | off = this.on(event, function(...args){ |
| 45 | off() |
| 46 | resolve(args.slice(0, -1)) // remove the extra argument at the end of our emit() |
| 47 | return listener?.(...args) |
| 48 | }) |
| 49 | }) |
| 50 | return Object.assign(off!, { then: pro.then.bind(pro) } satisfies PromiseLike<any> as typeof pro) |
| 51 | } |
| 52 | multi(map: { [eventName: string]: Listener }, options?: OnOptions) { |
| 53 | const cbs = Object.entries(map).map(([name, cb]) => this.on(name.split(' '), cb, options)) |
| 54 | return () => { |
| 55 | for (const cb of cbs) cb() |
| 56 | } |
| 57 | } |
| 58 | anyListener(event: string) { |
| 59 | return Boolean(this.listeners.get(event)?.size) |
| 60 | } |
| 61 | emit(event: string, ...args: any[]) { |
| 62 | let cbs = this.listeners.get(event) |
| 63 | if (!cbs?.size) return |
| 64 | const output: any[] = [] |
| 65 | let prevented = false |
| 66 | const extra = { |
| 67 | event, |
| 68 | output, |
| 69 | preventDefault() { prevented = true } |
| 70 | } |
| 71 | for (const cb of cbs) { |
| 72 | const res = cb(...args, extra) |
| 73 | if (res === this.stop) |
| 74 | extra.preventDefault() |
| 75 | else if (res !== undefined) |
| 76 | output.push(res) |
| 77 | } |
| 78 | return Object.assign(output, { |
| 79 | isDefaultPrevented: () => prevented, |
| 80 | }) |
| 81 | } |
| 82 | async emitAsync(event: string, ...args: any[]) { |
| 83 | const syncRet = this.emit(event, ...args) |
| 84 | if (!syncRet) return |
| 85 | const asyncRet: typeof syncRet = await Promise.all(syncRet) |
| 86 | return Object.assign(asyncRet, { |
| 87 | isDefaultPrevented: () => syncRet.isDefaultPrevented() |
| 88 | || asyncRet.some((r: any) => r === this.stop) |
| 89 | }) |
| 90 | } |
| 91 | } |
| 92 | |
| 93 | // app-wide events |
| 94 | export default new BetterEventEmitter |