From b615172ed1ddb648a3a01c38caba4932af55d043 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Mon, 10 Aug 2026 20:33:27 +0000 Subject: [PATCH] feat: strongly-type internal events * refactor wildcard emitter for better "any" usage * strongly type emitter with event map * fix webhook payload accessor #666 --- src/backend/common/WildcardEmitter.ts | 38 ++++++-- .../infrastructure/MSBackendEventMap.ts | 20 ++++ src/backend/ioc.ts | 11 ++- src/backend/notifier/Notifiers.ts | 15 +-- src/backend/scrobblers/ScrobbleClients.ts | 13 +-- src/backend/server/api.ts | 4 +- src/backend/sources/AbstractSource.ts | 2 +- src/backend/sources/ScrobbleSources.ts | 8 +- src/backend/tests/config/config.test.ts | 13 +-- .../tests/scrobbler/scrobblers.test.ts | 93 +++++++++++++------ src/backend/tests/source/source.test.ts | 11 ++- .../tests/utils/wildcardEmitter.test.ts | 53 +++++++++++ src/core/Atomic.ts | 10 +- src/core/MSCoreEventMap.ts | 14 +++ 14 files changed, 236 insertions(+), 69 deletions(-) create mode 100644 src/backend/common/infrastructure/MSBackendEventMap.ts create mode 100644 src/backend/tests/utils/wildcardEmitter.test.ts create mode 100644 src/core/MSCoreEventMap.ts diff --git a/src/backend/common/WildcardEmitter.ts b/src/backend/common/WildcardEmitter.ts index 89049b0e..a5b5967f 100644 --- a/src/backend/common/WildcardEmitter.ts +++ b/src/backend/common/WildcardEmitter.ts @@ -1,9 +1,35 @@ import EventEmitter from "events"; -export class WildcardEmitter extends EventEmitter { - emit(type: string, ...args: any) { - const argsWithName = args.length === 0 ? [type] : [...args, type]; - super.emit('*', ...argsWithName); - return super.emit(type, ...argsWithName) || super.emit('', ...argsWithName); +type DefaultEventMap = [never]; +type EventMap = Record | DefaultEventMap; +type Key = T extends DefaultEventMap ? string | symbol : K | keyof T; +type AnyRest = [...args: any[]]; +type Args = T extends DefaultEventMap ? AnyRest : ( + K extends keyof T ? T[K] : never + ); +type WildcardKey = T extends DefaultEventMap ? string | symbol : keyof T; +type WildcardHandler = (event: WildcardKey, ...args: unknown[]) => void; + +export class WildcardEmitter = DefaultEventMap> extends EventEmitter { + + private wildcardHandlers: Array> = []; + + emit(eventName: Key, ...args: Args): boolean { + this.wildcardHandlers.forEach((h) => h(eventName as WildcardKey, ...args)); + return super.emit(eventName, ...args); + } + + onAny(handler: WildcardHandler): () => void { + this.wildcardHandlers.push(handler); + return () => { + this.wildcardHandlers = this.wildcardHandlers.filter((h) => h !== handler); + }; + } + + removeAllListeners(eventName?: unknown): this { + if (eventName === undefined) { + this.wildcardHandlers = []; + } + return super.removeAllListeners(eventName as Key); } -} +} \ No newline at end of file diff --git a/src/backend/common/infrastructure/MSBackendEventMap.ts b/src/backend/common/infrastructure/MSBackendEventMap.ts new file mode 100644 index 00000000..baaf014d --- /dev/null +++ b/src/backend/common/infrastructure/MSBackendEventMap.ts @@ -0,0 +1,20 @@ +import type { Dayjs } from "dayjs"; +import type { EmittedMSEvent, PlayObject, SourcePlayerObj, SourceType } from "../../../core/Atomic.ts"; +import type { MSCoreEvents } from "../../../core/MSCoreEventMap.ts"; +import type { WebhookPayload } from "./config/health/webhooks.ts"; + +export interface MSBackendEventMap extends Omit { + notify: [EmittedMSEvent] + discoveredToScrobble: [EmittedMSEvent<{ + data: PlayObject | PlayObject[] + options: { + forceRefresh?: boolean, + [key: string]: any, + discoverLocation?: 'backlog' | [key: string] + checkTime: Dayjs + scrobbleFrom: string + scrobbleTo: string[] + } + }>] + playerUpdate: [EmittedMSEvent & { options: { scrobbleTo: string[] } },{},SourceType>] +} \ No newline at end of file diff --git a/src/backend/ioc.ts b/src/backend/ioc.ts index 4353643a..4e25d402 100644 --- a/src/backend/ioc.ts +++ b/src/backend/ioc.ts @@ -14,6 +14,7 @@ import prom from 'prom-client'; import { CoverArtApiClient } from "./common/vendor/musicbrainz/CoverArtApiClient.ts"; import { version } from "./version.ts"; import type {DbConcrete} from "./common/database/drizzle/drizzleUtils.ts"; +import type { MSBackendEventMap } from "./common/infrastructure/MSBackendEventMap.ts"; let root: ReturnType; export interface RootOptions { @@ -96,12 +97,14 @@ const createRoot = (options: RootOptions = {logger: loggerDebug}) => { dbFunc = async () => db; } - const cEmitter = new WildcardEmitter(); + const cEmitter = new WildcardEmitter(); // do nothing, just catch - cEmitter.on('error', (e) => null); - const sEmitter = new WildcardEmitter(); + cEmitter.on('error', (e) => { + logger.warn(new Error('Client emitter threw an error', {cause: e})); + }); + const sEmitter = new WildcardEmitter(); sEmitter.on('error', (e) => { - const f = e; + logger.warn(new Error('Source emitter threw an error', {cause: e})); }); const transformerManager = new TransformerManager(logger, maybeSingletonCache !== undefined ? maybeSingletonCache : cacheFunc()); diff --git a/src/backend/notifier/Notifiers.ts b/src/backend/notifier/Notifiers.ts index 0e607e06..14c9ad93 100644 --- a/src/backend/notifier/Notifiers.ts +++ b/src/backend/notifier/Notifiers.ts @@ -5,6 +5,7 @@ import type { AbstractWebhookNotifier } from "./AbstractWebhookNotifier.ts"; import { AppriseWebhookNotifier } from "./AppriseWebhookNotifier.ts"; import { GotifyWebhookNotifier } from "./GotifyWebhookNotifier.ts"; import { NtfyWebhookNotifier } from "./NtfyWebhookNotifier.ts"; +import type { MSBackendEventMap } from '../common/infrastructure/MSBackendEventMap.ts'; export class Notifiers { @@ -14,21 +15,21 @@ export class Notifiers { emitter: EventEmitter; - clientEmitter: EventEmitter; - sourceEmitter: EventEmitter; + clientEmitter: EventEmitter; + sourceEmitter: EventEmitter; - constructor(emitter: EventEmitter, clientEmitter: EventEmitter, sourceEmitter: EventEmitter, parentLogger: Logger) { + constructor(emitter: EventEmitter, clientEmitter: EventEmitter, sourceEmitter: EventEmitter, parentLogger: Logger) { this.emitter = emitter; this.clientEmitter = clientEmitter; this.sourceEmitter = sourceEmitter; this.logger = childLogger(parentLogger, 'Notifiers'); - this.sourceEmitter.on('notify', async (payload: WebhookPayload) => { - await this.notify(payload); + this.sourceEmitter.on('notify', async (payload) => { + await this.notify(payload.data); }) - this.clientEmitter.on('notify', async (payload: WebhookPayload) => { - await this.notify(payload); + this.clientEmitter.on('notify', async (payload) => { + await this.notify(payload.data); }) } diff --git a/src/backend/scrobblers/ScrobbleClients.ts b/src/backend/scrobblers/ScrobbleClients.ts index 9adefe35..a3152875 100644 --- a/src/backend/scrobblers/ScrobbleClients.ts +++ b/src/backend/scrobblers/ScrobbleClients.ts @@ -18,6 +18,7 @@ import { prettifyError, ZodError } from 'zod'; import { commonComponentEnvConfigToConfigPrimitives, generateCommonComponentEnvConfigSchema, generateConfigLocation, transformPresetEnv, type CommonConfigPrimitives, type UnparsedConfig } from '../common/infrastructure/config/common.ts'; import type { CommonClientConfig } from '../common/infrastructure/config/client/index.ts'; import { getClientEnvSchema, validateClientAIOJson, validateClientJson, type ClientTypeConfigMap } from '../common/infrastructure/config/client/clientsMap.ts'; +import type { MSBackendEventMap } from '../common/infrastructure/MSBackendEventMap.ts'; type UnparsedClientConfig = UnparsedConfig; @@ -37,13 +38,13 @@ export default class ScrobbleClients { internalConfig: InternalConfig; - emitter: WildcardEmitter; + emitter: WildcardEmitter; - sourceEmitter: WildcardEmitter; + sourceEmitter: WildcardEmitter; scrobbleToNamesWarnings: string[] = []; - constructor(emitter: WildcardEmitter, sourceEmitter: WildcardEmitter, internal: InternalConfigOptional, parentLogger: Logger) { + constructor(emitter: WildcardEmitter, sourceEmitter: WildcardEmitter, internal: InternalConfigOptional, parentLogger: Logger) { this.emitter = emitter; this.sourceEmitter = sourceEmitter; this.logger = childLogger(parentLogger, 'Scrobblers'); // winston.loggers.get('app').child({labels: ['Scrobblers']}, mergeArr); @@ -52,14 +53,14 @@ export default class ScrobbleClients { logger: this.logger } - this.sourceEmitter.on('playerUpdate', async (payload: { data: SourcePlayerObj & { options: { scrobbleTo: string[] } }} & SourceIdentifier) => { + this.sourceEmitter.on('playerUpdate', async (payload) => { // agressively update Now Playing so scrobblers that display based on duration are mostly synced // but aggressively *stop* updating if state becomes stale/orphaned this.playingNow(payload.data, {...payload.data.options, scrobbleFrom: { type: payload.type, name: payload.name}}); }); - this.sourceEmitter.on('discoveredToScrobble', async (payload: { data: (PlayObject | PlayObject[]), options: { forceRefresh?: boolean, checkTime?: Dayjs, scrobbleTo?: string[], scrobbleFrom?: string } }) => { - await this.scrobble(payload.data, payload.options); + this.sourceEmitter.on('discoveredToScrobble', async (payload) => { + await this.scrobble(payload.data.data, payload.data.options); }); } diff --git a/src/backend/server/api.ts b/src/backend/server/api.ts index a98467b8..20e01502 100644 --- a/src/backend/server/api.ts +++ b/src/backend/server/api.ts @@ -159,7 +159,7 @@ export const setupApi = (app: Express, logger: Logger, appLoggerStream: PassThro const isNextapi = nextQs === 'true'; const session = await bsseDef.createSession(req, res); - scrobbleSources.emitter.on('*', (payload: any, eventName: string) => { + scrobbleSources.emitter.onAny((eventName: string, payload: any) => { if(payload.from !== undefined) { if(isNextapi) { session.push({event: eventName, ...payload}, eventName); @@ -168,7 +168,7 @@ export const setupApi = (app: Express, logger: Logger, appLoggerStream: PassThro } } }); - scrobbleClients.emitter.on('*', (payload: any, eventName: string) => { + scrobbleClients.emitter.onAny((eventName: string, payload: any) => { if(payload.from !== undefined) { if(isNextapi) { session.push({event: eventName, ...payload}, eventName); diff --git a/src/backend/sources/AbstractSource.ts b/src/backend/sources/AbstractSource.ts index ce618318..1598696b 100644 --- a/src/backend/sources/AbstractSource.ts +++ b/src/backend/sources/AbstractSource.ts @@ -423,7 +423,7 @@ export default abstract class AbstractSource extends AbstractComponent implement if(newDiscoveredPlays.length > 0) { newDiscoveredPlays.sort(sortByOldestPlayDate); - this.emitter.emit('discoveredToScrobble', { + this.emitEvent('discoveredToScrobble', { data: await pMap(newDiscoveredPlays, this.staggerMappers.postCompare(async (x) => await this.transformPlay(x, TRANSFORM_HOOK.postCompare)), {concurrency: 3}), options: { ...options, diff --git a/src/backend/sources/ScrobbleSources.ts b/src/backend/sources/ScrobbleSources.ts index c5ebc2a1..43c31ff1 100644 --- a/src/backend/sources/ScrobbleSources.ts +++ b/src/backend/sources/ScrobbleSources.ts @@ -1,6 +1,5 @@ import { childLogger, type Logger } from '@foxxmd/logging'; -import type EventEmitter from "events"; import type {InternalConfig, InternalConfigOptional} from "../common/infrastructure/Atomic.ts"; import { clientTypes, isSourceType } from "../../core/Atomic.ts"; import { sourceTypes } from "../../core/Atomic.ts"; @@ -18,6 +17,7 @@ import { commonComponentEnvConfigToConfigPrimitives, generateCommonComponentEnvC import { getSourceEnvSchema, validateSourceAIOJson, validateSourceJson } from '../common/infrastructure/config/source/sourcesMap.ts'; import type { SourceTypeConfigMap } from "../common/infrastructure/config/source/sourcesMap.ts"; import { stripIndents } from 'common-tags'; +import type { MSBackendEventMap } from '../common/infrastructure/MSBackendEventMap.ts'; type UnparsedSourceConfig = UnparsedConfig; @@ -29,9 +29,9 @@ export default class ScrobbleSources { logger: Logger; internalConfig: InternalConfig; - emitter: WildcardEmitter; + emitter: WildcardEmitter; - constructor(emitter: EventEmitter, internal: InternalConfigOptional, parentLogger: Logger) { + constructor(emitter: WildcardEmitter, internal: InternalConfigOptional, parentLogger: Logger) { this.emitter = emitter; this.logger = childLogger(parentLogger, 'Sources'); this.internalConfig = { @@ -309,7 +309,7 @@ export default class ScrobbleSources { sourceType: T, strongConfigs: CommonParsedConfig[], defaults: SourceDefaults, - Ctor: new (name: string, config: SourceTypeConfigMap[T][0], internalConfig: InternalConfig, emitter: WildcardEmitter) => AbstractSource, + Ctor: new (name: string, config: SourceTypeConfigMap[T][0], internalConfig: InternalConfig, emitter: WildcardEmitter) => AbstractSource, ) => { for (const s of strongConfigs) { try { diff --git a/src/backend/tests/config/config.test.ts b/src/backend/tests/config/config.test.ts index 650b647d..90c73dcb 100644 --- a/src/backend/tests/config/config.test.ts +++ b/src/backend/tests/config/config.test.ts @@ -7,6 +7,7 @@ import path from "path"; import ScrobbleClients from '../../scrobblers/ScrobbleClients.ts'; import ScrobbleSources from '../../sources/ScrobbleSources.ts'; import EventEmitter from "events"; +import { WildcardEmitter } from '../../common/WildcardEmitter.ts'; import {loggerTest} from '@foxxmd/logging'; import { clientTypes } from "../../../core/Atomic.ts"; import { projectRootDir } from "../../common/infrastructure/Atomic.ts"; @@ -16,6 +17,7 @@ import { validateSourceJson } from '../../common/infrastructure/config/source/so import { readJson } from '../../utils/DataUtils.ts'; import { prettifyError, ZodError } from 'zod'; import { validateClientJson } from '../../common/infrastructure/config/client/clientsMap.ts'; +import type { MSBackendEventMap } from '../../common/infrastructure/MSBackendEventMap.ts'; chai.use(asPromised); @@ -61,7 +63,6 @@ describe('Sample Configs', function () { it(`Sample ${componentType}.json parses and validates in isolation`, async function () { this.timeout(5000); - const emitter = new EventEmitter(); await copyFile(samplePath(componentType), `${componentType}.json`); let fileContents = await readJson(`${componentType}.json`); @@ -82,7 +83,7 @@ describe('Sample Configs', function () { it(`Sample ${componentType}.json parses and validates in ScrobbleSources`, async function () { this.timeout(5000); - const emitter = new EventEmitter(); + const emitter = new WildcardEmitter(); await copyFile(samplePath(componentType), `${componentType}.json`); const sources = new ScrobbleSources(emitter, { localUrl: new URL('http://example.com'), @@ -136,9 +137,9 @@ describe('Sample Configs', function () { it(`Sample ${componentType}.json parses and validates in ScrobbleClients`, async function () { this.timeout(500000); - const emitter = new EventEmitter(); + const emitter = new WildcardEmitter(); await copyFile(samplePath(componentType), `${componentType}.json`); - const clients = new ScrobbleClients(emitter, new EventEmitter, { + const clients = new ScrobbleClients(emitter, new WildcardEmitter, { localUrl: new URL('http://example.com'), configDir: process.cwd(), version: 'test' @@ -172,7 +173,7 @@ describe('Global ENVs with Config', function () { process.env.MPRIS_ID = 'test'; process.env.MPRIS_ENABLE = 'true'; - const emitter = new EventEmitter(); + const emitter = new WildcardEmitter(); const sources = new ScrobbleSources(emitter, { localUrl: new URL('http://example.com'), configDir: process.cwd(), @@ -189,7 +190,7 @@ describe('Global ENVs with Config', function () { process.env.MPRIS_ID = 'test'; process.env.MPRIS_ENABLE = 'true'; - const emitter = new EventEmitter(); + const emitter = new WildcardEmitter(); const sources = new ScrobbleSources(emitter, { localUrl: new URL('http://example.com'), configDir: process.cwd(), diff --git a/src/backend/tests/scrobbler/scrobblers.test.ts b/src/backend/tests/scrobbler/scrobblers.test.ts index c52a0740..e3761997 100644 --- a/src/backend/tests/scrobbler/scrobblers.test.ts +++ b/src/backend/tests/scrobbler/scrobblers.test.ts @@ -32,6 +32,7 @@ import ScrobbleClients from '../../scrobblers/ScrobbleClients.ts'; import { WildcardEmitter } from '../../common/WildcardEmitter.ts'; import type { CommonClientConfig } from '../../common/infrastructure/config/client/index.ts'; import { loggerNoop } from '../../common/MaybeLogger.ts'; +import type { MSBackendEventMap } from '../../common/infrastructure/MSBackendEventMap.ts'; chai.use(asPromised); @@ -1148,13 +1149,13 @@ describe('Scrobble Clients Behavior', function() { describe('Source filtering', function() { - let cEmitter: WildcardEmitter, - sEmitter: WildcardEmitter, + let cEmitter: WildcardEmitter, + sEmitter: WildcardEmitter, clients: ScrobbleClients; beforeEach(function() { - cEmitter = new WildcardEmitter(); - sEmitter = new WildcardEmitter(); + cEmitter = new WildcardEmitter(); + sEmitter = new WildcardEmitter(); clients = new ScrobbleClients(cEmitter, sEmitter, { localUrl: new URL('http://example.com'), configDir: process.cwd(), @@ -1169,10 +1170,16 @@ describe('Scrobble Clients Behavior', function() { clients.clients.push(testClient); sEmitter.emit('discoveredToScrobble', { - data: [generatePlay()], - options: { - scrobbleFrom: 'testSource', - scrobbleTo: ['foo'] + type: 'jellyfin', + from: 'source', + name: 'test', + data: { + data: [generatePlay()], + options: { + scrobbleFrom: 'testSource', + scrobbleTo: ['foo'], + checkTime: dayjs() + } } }); expect(clients.scrobbleToNamesWarnings).is.empty; @@ -1190,10 +1197,16 @@ describe('Scrobble Clients Behavior', function() { clients.clients.push(testClient); sEmitter.emit('discoveredToScrobble', { - data: [generatePlay()], - options: { - scrobbleFrom: 'testSource', - scrobbleTo: ['test'] + type: 'jellyfin', + from: 'source', + name: 'test', + data: { + data: [generatePlay()], + options: { + scrobbleFrom: 'testSource', + scrobbleTo: ['test'], + checkTime: dayjs() + } } }); expect(clients.scrobbleToNamesWarnings).length.greaterThan(0); @@ -1211,10 +1224,16 @@ describe('Scrobble Clients Behavior', function() { clients.clients.push(testClient); sEmitter.emit('discoveredToScrobble', { - data: [generatePlay()], - options: { - scrobbleFrom: 'testSource', - scrobbleTo: ['test'] + type: 'jellyfin', + from: 'source', + name: 'test', + data: { + data: [generatePlay()], + options: { + scrobbleFrom: 'testSource', + scrobbleTo: ['test'], + checkTime: dayjs() + } } }); expect(clients.scrobbleToNamesWarnings).is.empty; @@ -1232,10 +1251,16 @@ describe('Scrobble Clients Behavior', function() { clients.clients.push(testClient); sEmitter.emit('discoveredToScrobble', { - data: [generatePlay()], - options: { - scrobbleFrom: 'testSource', - scrobbleTo: ['test foo'] + type: 'jellyfin', + from: 'source', + name: 'test', + data: { + data: [generatePlay()], + options: { + scrobbleFrom: 'testSource', + scrobbleTo: ['test foo'], + checkTime: dayjs() + } } }); expect(clients.scrobbleToNamesWarnings).is.empty; @@ -1253,10 +1278,16 @@ describe('Scrobble Clients Behavior', function() { clients.clients.push(testClient); sEmitter.emit('discoveredToScrobble', { - data: [generatePlay()], - options: { - scrobbleFrom: 'testSource', - scrobbleTo: [] + type: 'jellyfin', + from: 'source', + name: 'test', + data: { + data: [generatePlay()], + options: { + scrobbleFrom: 'testSource', + scrobbleTo: [], + checkTime: dayjs() + } } }); expect(clients.scrobbleToNamesWarnings).is.empty; @@ -1274,10 +1305,16 @@ describe('Scrobble Clients Behavior', function() { clients.clients.push(testClient); sEmitter.emit('discoveredToScrobble', { - data: [generatePlay()], - options: { - scrobbleFrom: 'testSource', - scrobbleTo: ['TesT foO'] + type: 'jellyfin', + from: 'source', + name: 'test', + data: { + data: [generatePlay()], + options: { + scrobbleFrom: 'testSource', + scrobbleTo: ['TesT foO'], + checkTime: dayjs() + } } }); expect(clients.scrobbleToNamesWarnings).is.empty; diff --git a/src/backend/tests/source/source.test.ts b/src/backend/tests/source/source.test.ts index 56488e6e..d855a608 100644 --- a/src/backend/tests/source/source.test.ts +++ b/src/backend/tests/source/source.test.ts @@ -21,11 +21,13 @@ import DeezerInternalSource from "../../sources/DeezerInternalSource.ts"; import type {DeezerInternalSourceOptions} from "../../common/infrastructure/config/source/deezer.ts"; import { artistCreditsToNames } from "../../../core/StringUtils.ts"; import type { MarkOptional } from "ts-essentials"; +import { WildcardEmitter } from "../../common/WildcardEmitter.ts"; +import type { MSBackendEventMap } from "../../common/infrastructure/MSBackendEventMap.ts"; chai.use(asPromised); -const emitter = new EventEmitter(); +const emitter = new WildcardEmitter(); const generateSource = async () => { const source = new TestSource('spotify', 'test-basic', {id: `test-${Date.now()}`}, {localUrl: new URL('https://example.com'), configDir: 'fake', logger: loggerTest, version: 'test'}, emitter); await source.initialize(); @@ -96,11 +98,12 @@ describe('Sources use transform plays correctly', function () { expect(discovered.length).eq(1); expect(discovered[0].data.track).is.eq('my cool track'); - const pAwaiter = pEvent(source.emitter, 'discoveredToScrobble') as Promise<{data: [PlayObject] }>; + const pAwaiter = pEvent(source.emitter, 'discoveredToScrobble') as Promise; source.handle(discovered); const e = await pAwaiter; - expect(e.data.length).is.eq(1); - expect(e.data[0].data.track).is.eq('my fun track'); + const res: PlayObject[] = !Array.isArray(e.data.data) ? [e.data.data] : e.data.data; + expect(res.length).is.eq(1); + expect(res[0].data.track).is.eq('my fun track'); }); it('Transforms play existing comparison', async function() { diff --git a/src/backend/tests/utils/wildcardEmitter.test.ts b/src/backend/tests/utils/wildcardEmitter.test.ts new file mode 100644 index 00000000..3bd46ce5 --- /dev/null +++ b/src/backend/tests/utils/wildcardEmitter.test.ts @@ -0,0 +1,53 @@ +import { expect } from 'chai'; +import { describe, it } from 'mocha'; +import { WildcardEmitter } from '../../common/WildcardEmitter.ts'; + +describe('WildcardEmitter', function () { + + it('invokes onAny listeners for any emitted event', function () { + const emitter = new WildcardEmitter(); + const seen: [string | symbol, unknown[]][] = []; + emitter.onAny((event, ...args) => seen.push([event, args])); + + emitter.emit('foo', 1, 2); + emitter.emit('bar', 'baz'); + + expect(seen).to.deep.equal([ + ['foo', [1, 2]], + ['bar', ['baz']], + ]); + }); + + it('still invokes normal listeners registered with on', function () { + const emitter = new WildcardEmitter(); + let received: unknown; + emitter.on('foo', (val) => { received = val; }); + + emitter.emit('foo', 'hello'); + + expect(received).to.eq('hello'); + }); + + it('stops invoking a handler after its unsubscribe function is called', function () { + const emitter = new WildcardEmitter(); + let calls = 0; + const unsubscribe = emitter.onAny(() => { calls++; }); + + emitter.emit('foo'); + unsubscribe(); + emitter.emit('foo'); + + expect(calls).to.eq(1); + }); + + it('removeAllListeners with no args clears wildcard handlers', function () { + const emitter = new WildcardEmitter(); + let calls = 0; + emitter.onAny(() => { calls++; }); + + emitter.removeAllListeners(); + emitter.emit('foo'); + + expect(calls).to.eq(0); + }); +}); diff --git a/src/core/Atomic.ts b/src/core/Atomic.ts index 7ef1611e..74c09ca9 100644 --- a/src/core/Atomic.ts +++ b/src/core/Atomic.ts @@ -1,6 +1,6 @@ import type { Dayjs } from "dayjs"; import type { AdditionalTrackInfoResponse } from "./vendor/listenbrainz/interfaces.ts"; -import type { Merge, RequiredKeys, StrictOmit } from "ts-essentials"; +import type { MarkRequired, Merge, RequiredKeys, StrictOmit } from "ts-essentials"; import type {ErrorObject} from "serialize-error"; import type { FlowControlTerm, TransformHook } from "./Transform.ts"; import type {Changeset} from "json-diff-ts"; @@ -807,3 +807,11 @@ export const NO_DEVICE = 'NoDevice';export const NO_USER = 'SingleUser'; export const SINGLE_USER_PLATFORM_ID: PlayPlatformId = [NO_DEVICE, NO_USER]; export const SINGLE_USER_PLATFORM_ID_STR = `${NO_DEVICE}-${NO_USER}`; +export type EmittedMSEvent, K = Record,Y = ClientType | SourceType> = { + type: Y + name: string + componentId?: number + from: ComponentType + data: T + options?: K +} \ No newline at end of file diff --git a/src/core/MSCoreEventMap.ts b/src/core/MSCoreEventMap.ts new file mode 100644 index 00000000..a63c3ffd --- /dev/null +++ b/src/core/MSCoreEventMap.ts @@ -0,0 +1,14 @@ +import type { MarkRequired } from "ts-essentials" +import type { PlayApiCommonDetailed } from "./Api.ts" +import type { EmittedMSEvent, JsonPlayObject, SourcePlayerObj } from "./Atomic.ts" + +export interface MSCoreEvents { + playInsert: [EmittedMSEvent] + playUpdate: [EmittedMSEvent, 'uid'>>] + playerUpdate: [EmittedMSEvent>] + playerDelete: [EmittedMSEvent<{platformId: string},{options: {scrobbleTo: string[]}}>] + scrobble: [EmittedMSEvent<{play: JsonPlayObject}>] + discovered: [EmittedMSEvent<{play: JsonPlayObject}>] + statusChange: [EmittedMSEvent<{status: string}>] + error: [unknown] +} \ No newline at end of file -- 2.51.2