import { defineProperty } from 'cosmokit' import type { Promisify } from 'cosmokit' import { Context } from './context.ts' import { Fiber, FiberState } from './fiber.ts' import { DisposableList, symbols } from './utils.ts' /** * Return whether an event result should stop a bail-style dispatch. * * @param value — a listener's return value. * @returns `true` unless `value` is `null`, `false`, or `undefined`. */ export function isBailed(value: any) { return value !== null && value !== false && value !== undefined } /** Extract the parameter tuple from a function type. */ export type Parameters = F extends (...args: infer P) => any ? P : never /** Extract the return type from a function type. */ export type ReturnType = F extends (...args: any) => infer R ? R : never /** Extract the explicit `this` type from a function type. */ export type ThisType = F extends (this: infer T, ...args: any) => any ? T : never /** * Event dispatch strategy used by the event service. * * `emit` runs synchronous listeners without awaiting them, `parallel` awaits * all listeners together, `serial` awaits them in order until one bails, * `bail` stops on the first synchronous bail value, and `waterfall` composes * listeners around a final `next` callback. */ export type DispatchMode = 'emit' | 'parallel' | 'serial' | 'bail' | 'waterfall' declare module './context.ts' { export interface Context { /* eslint-disable max-len */ /** * Dispatch an event, running all listeners concurrently. * * @param name — the event name. * @param args — arguments passed to every listener. * @returns a promise resolving once every listener has settled. */ parallel(name: K, ...args: Parameters): Promise /** Same as above, with an explicit `this` for listeners (also used for filtering). */ parallel(thisArg: NoInfer>, name: K, ...args: Parameters): Promise /** * Dispatch an event synchronously, ignoring listener return values. * * @param name — the event name. * @param args — arguments passed to every listener. */ emit(name: K, ...args: Parameters): void /** Same as above, with an explicit `this` for listeners (also used for filtering). */ emit(thisArg: NoInfer>, name: K, ...args: Parameters): void /** * Dispatch an event, awaiting listeners in order until one bails. * * @param name — the event name. * @param args — arguments passed to each listener. * @returns the first bail value (non-null, non-false, non-undefined), if any. */ serial(name: K, ...args: Parameters): Promisify> /** Same as above, with an explicit `this` for listeners (also used for filtering). */ serial(thisArg: NoInfer>, name: K, ...args: Parameters): Promisify> /** * Dispatch an event, calling listeners in order until one bails. * * @param name — the event name. * @param args — arguments passed to each listener. * @returns the first bail value (non-null, non-false, non-undefined), if any. */ bail(name: K, ...args: Parameters): ReturnType /** Same as above, with an explicit `this` for listeners (also used for filtering). */ bail(thisArg: NoInfer>, name: K, ...args: Parameters): ReturnType /** * Dispatch an event whose last argument is a `next` continuation. * * Each listener wraps the rest of the chain: calling `next()` invokes the * next listener (finally the built-in behavior); not calling it vetoes. * * @param name — the event name. * @param args — listener arguments; the final one is the innermost `next`. * @returns the outermost listener's return value. */ waterfall(name: K, ...args: Parameters): ReturnType /** Same as above, with an explicit `this` for listeners (also used for filtering). */ waterfall(thisArg: NoInfer>, name: K, ...args: Parameters): ReturnType /** * Register an event listener owned by the current fiber. * * @param name — the event name to listen for. * @param listener — called with the dispatch arguments. * @param options — listener options; a boolean is shorthand for `prepend`. * @returns a disposer removing the listener; `true` if it was still registered. */ on(name: K, listener: Events[K], options?: boolean | EventOptions): () => boolean /** * Same as `on()`, but the listener disposes itself after its first call. * * @param name — the event name to listen for. * @param listener — called at most once with the dispatch arguments. * @param options — listener options; a boolean is shorthand for `prepend`. * @returns a disposer removing the listener; `true` if it was still registered. */ once(name: K, listener: Events[K], options?: boolean | EventOptions): () => boolean /* eslint-enable max-len */ } } /** Options accepted by `ctx.on()` and `ctx.once()`. */ export interface EventOptions { /** Add the listener before existing listeners for the same event. */ prepend?: boolean /** Receive the event regardless of context filter checks. */ global?: boolean } /** Registered listener record stored by the event service. */ export interface Hook extends EventOptions { ctx: Context callback: (...args: any[]) => any } /** * Event bus installed as `ctx.events` and mixed into every context. * * The service supports concurrent, synchronous, serial, bail, and waterfall * dispatch and automatically disposes listeners with their owning fiber. */ export class EventsService { _hooks: Record = {} constructor(private ctx: Context) { defineProperty(this, symbols.tracker, { property: 'ctx', noShadow: true, }) this.on('internal/listener', function (this: Context, name, listener, options: EventOptions) { if (name === 'internal/update' && !options.global) { const hooks = this.fiber._hooks['internal/update'] ??= new DisposableList() const method = options.prepend ? 'unshift' : 'push' return hooks[method](listener) } }) this.on('internal/update', function (config, noSave, next) { const cbs = [...this._hooks['internal/update'] || []] const _next = () => { const cb = cbs.shift() ?? next return cb.call(this, config, noSave, _next) } return _next() }, { global: true, prepend: true }) } /** * Resolve listeners for one dispatch and apply context filtering. * * @param type — the dispatch mode, reported on `internal/dispatch`. * @param args — the raw dispatch arguments; consumed up to the event name. * @returns the matching listener callbacks, bound to the dispatch `this`. */ dispatch(type: string, args: any[]) { const thisArg = typeof args[0] === 'object' || typeof args[0] === 'function' ? args.shift() : null const name: string = args.shift() if (!name.startsWith('internal/')) { this.emit('internal/dispatch', type, name, args, thisArg) } const filter = thisArg?.[Context.filter] return (this._hooks[name] || []) .filter(hook => hook.global || !filter || filter.call(thisArg, hook.ctx)) .map(hook => hook.callback.bind(thisArg)) } /** * Run listeners concurrently and wait for all of them. * * @param args — optional `this`, the event name, then listener arguments. * @returns a promise resolving once every listener has settled. */ async parallel(...args: any[]) { const results = await Promise.allSettled(this.dispatch('emit', args).map(async cb => cb(...args))) const errors = results.filter((result): result is PromiseRejectedResult => result.status === 'rejected') if (errors.length) throw new AggregateError(errors.map(error => error.reason)) } /** * Run listeners synchronously without waiting for returned promises. * * @param args — optional `this`, the event name, then listener arguments. */ emit(...args: any[]) { this.dispatch('emit', args).map(cb => cb(...args)) } /** * Run listeners in order, awaiting each, until one returns a bail value. * * @param args — optional `this`, the event name, then listener arguments. * @returns the first bail value (see {@link isBailed}), if any. */ async serial(...args: any[]) { for (const cb of this.dispatch('serial', args)) { const result = await cb(...args) if (isBailed(result)) return result } } /** * Run listeners synchronously until one returns a bail value. * * @param args — optional `this`, the event name, then listener arguments. * @returns the first bail value (see {@link isBailed}), if any. */ bail(...args: any[]) { for (const cb of this.dispatch('bail', args)) { const result = cb(...args) if (isBailed(result)) return result } } /** * Compose listeners around the final `next` callback. * * The last dispatch argument is treated as the innermost `next`. Listeners * run outermost-first; a listener that does not call `next()` vetoes the * rest of the chain, including the built-in behavior. * * @param args — optional `this`, the event name, listener arguments, then `next`. * @returns the outermost listener's return value. */ waterfall(...args: any[]) { const cbs = this.dispatch('waterfall', args) const inner = args.pop() const next = () => { const cb = cbs.shift() ?? inner return cb(...args) } args.push(next) return next() } /** * Store a listener record as an effect on the current fiber. * * @param label — effect label shown in fiber diagnostics. * @param hooks — the listener list for one event. * @param callback — the listener to store. * @param options — placement and filtering options. * @returns a disposer that unregisters the listener. */ register(label: string, hooks: Hook[], callback: any, options: EventOptions): () => void { const method = options.prepend ? 'unshift' : 'push' return this.ctx.fiber.effect(() => { hooks[method]({ ctx: this.ctx, callback, ...options }) return () => this.unregister(hooks, callback) }, label) } /** * Remove a stored listener record. * * @param hooks — the listener list for one event. * @param callback — the listener to remove. * @returns `true` if the listener was found and removed. */ unregister(hooks: Hook[], callback: any) { const index = hooks.findIndex(hook => hook.callback === callback) if (index >= 0) { hooks.splice(index, 1) return true } } /** * Register an event listener owned by the current fiber. * * The listener is removed automatically when the fiber unloads. Throws * `CordisError('INACTIVE_EFFECT')` if the fiber is already disposed. * * @param name — the event name to listen for. * @param listener — called with the dispatch arguments. * @param options — listener options; a boolean is shorthand for `prepend`. * @returns a disposer removing the listener; `true` if it was still registered. */ on(name: string | symbol, listener: (...args: any) => any, options?: boolean | EventOptions) { if (typeof options !== 'object') { options = { prepend: options } } // handle special events this.ctx.fiber.assertActive() listener = this.ctx.reflect.bind(listener) const result = this.bail(this.ctx, 'internal/listener', name, listener, options) if (result) return result const hooks = this._hooks[name] ||= [] const label = `ctx.on(${typeof name === 'string' ? JSON.stringify(name) : name.toString()})` return this.register(label, hooks, listener, options) } /** * Register an event listener that disposes itself after the first call. * * @param name — the event name to listen for. * @param listener — called at most once with the dispatch arguments. * @param options — listener options; a boolean is shorthand for `prepend`. * @returns a disposer removing the listener; `true` if it was still registered. */ once(name: string, listener: (...args: any) => any, options?: boolean | EventOptions) { const dispose = this.on(name, function (...args: any[]) { dispose() return listener.apply(this, args) }, options) return dispose } } /** * Built-in framework events used by core services and extension points. * * Plugin and status events track fiber lifecycle, service events observe * dependency registration, update/get/set/listener events allow core services * to intercept runtime operations, and `internal/dispatch` exposes event-bus * diagnostics before public events are delivered. */ export interface Events { /** A plugin fiber was created or its uid was cleared on disposal. */ 'internal/plugin'(fiber: Fiber): void /** A fiber changed lifecycle state; receives the fiber and its previous state. */ 'internal/status'(fiber: Fiber, oldValue: FiberState): void /** Interception hook for a service binding (no core producer). */ 'internal/service'(this: Context, name: string, value: any): void /** Waterfall: a fiber config update is being applied; skip `next()` to veto. */ 'internal/update'(this: Fiber, config: any, noSave: boolean, next: () => void): void /** Waterfall: a service is being read through the context proxy. */ 'internal/get'(ctx: Context, name: string, error: Error, next: () => any): any /** Waterfall: a service is being written through the context proxy. */ 'internal/set'(ctx: Context, name: string, value: any, error: Error, next: () => boolean): boolean /** Bail: a listener is being registered; a non-null result replaces registration. */ 'internal/listener'(this: Context, name: string, listener: any, prepend: boolean): void /** An event is being dispatched to listeners (fired for non-internal events only). */ 'internal/dispatch'(mode: DispatchMode, name: string, args: any[], thisArg: any): void }