diff --git a/.componentsignore b/.componentsignore index b157661cd..594027e01 100644 --- a/.componentsignore +++ b/.componentsignore @@ -1,6 +1,7 @@ [ "AccessMap", "Adapter", + "BaseActivityEmitter", "BaseHttpError", "BasicConditions", "BasicRepresentation", @@ -9,6 +10,7 @@ "Dict", "Error", "EventEmitter", + "GenericEventEmitter", "HashMap", "HttpErrorOptions", "HttpResponse", diff --git a/src/index.ts b/src/index.ts index 98dff28d3..33219507a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -300,6 +300,9 @@ export * from './server/middleware/HeaderHandler'; export * from './server/middleware/StaticAssetHandler'; export * from './server/middleware/WebSocketAdvertiser'; +// Server/Notifications +export * from './server/notifications/ActivityEmitter'; + // Server/Util export * from './server/util/RedirectingHttpHandler'; export * from './server/util/RouterHandler'; @@ -470,6 +473,7 @@ export * from './util/templates/TemplateUtil'; // Util export * from './util/ContentTypes'; export * from './util/FetchUtil'; +export * from './util/GenericEventEmitter'; export * from './util/GuardedStream'; export * from './util/HeaderUtil'; export * from './util/IterableUtil'; diff --git a/src/server/notifications/ActivityEmitter.ts b/src/server/notifications/ActivityEmitter.ts new file mode 100644 index 000000000..5f111fb4a --- /dev/null +++ b/src/server/notifications/ActivityEmitter.ts @@ -0,0 +1,18 @@ +import type { ResourceIdentifier } from '../../http/representation/ResourceIdentifier'; +import type { GenericEventEmitter } from '../../util/GenericEventEmitter'; +import { createGenericEventEmitterClass } from '../../util/GenericEventEmitter'; +import type { AS, VocabularyTerm, VocabularyValue } from '../../util/Vocabularies'; + +/** + * An event emitter used to report changes made to resources. + * Both generic `change` events and ActivityStream-specific events are emitted. + */ +export type ActivityEmitter = + GenericEventEmitter<'changed', (target: ResourceIdentifier, activity: VocabularyTerm) => void> & + GenericEventEmitter, (target: ResourceIdentifier) => void>; + +/** + * A class implementation of {@link ActivityEmitter}. + */ +// eslint-disable-next-line @typescript-eslint/naming-convention +export const BaseActivityEmitter = createGenericEventEmitterClass(); diff --git a/src/storage/MonitoringStore.ts b/src/storage/MonitoringStore.ts index 2069b63cf..825d21e20 100644 --- a/src/storage/MonitoringStore.ts +++ b/src/storage/MonitoringStore.ts @@ -1,21 +1,22 @@ -import { EventEmitter } from 'events'; +import type { Term } from '@rdfjs/types'; import type { Patch } from '../http/representation/Patch'; import type { Representation } from '../http/representation/Representation'; import type { RepresentationPreferences } from '../http/representation/RepresentationPreferences'; import type { ResourceIdentifier } from '../http/representation/ResourceIdentifier'; +import { BaseActivityEmitter } from '../server/notifications/ActivityEmitter'; import { AS, SOLID_AS } from '../util/Vocabularies'; import type { Conditions } from './Conditions'; import type { ResourceStore, ChangeMap } from './ResourceStore'; // The ActivityStream terms for which we emit an event -const emittedActivities: Set = new Set([ AS.Create, AS.Delete, AS.Update ]); +const knownActivities = [ AS.terms.Create, AS.terms.Delete, AS.terms.Update ]; /** * Store that notifies listeners of changes to its source * by emitting a `changed` event. */ export class MonitoringStore - extends EventEmitter implements ResourceStore { + extends BaseActivityEmitter implements ResourceStore { private readonly source: T; public constructor(source: T) { @@ -55,12 +56,16 @@ export class MonitoringStore private emitChanged(changes: ChangeMap): ChangeMap { for (const [ identifier, metadata ] of changes) { const activity = metadata.get(SOLID_AS.terms.Activity); - this.emit('changed', identifier, activity); - if (activity && emittedActivities.has(activity.value)) { + if (this.isKnownActivity(activity)) { + this.emit('changed', identifier, activity); this.emit(activity.value, identifier); } } return changes; } + + private isKnownActivity(term?: Term): term is typeof knownActivities[number] { + return Boolean(term && knownActivities.some((entry): boolean => entry.equals(term))); + } } diff --git a/src/util/GenericEventEmitter.ts b/src/util/GenericEventEmitter.ts new file mode 100644 index 000000000..3183a18b2 --- /dev/null +++ b/src/util/GenericEventEmitter.ts @@ -0,0 +1,36 @@ +import { EventEmitter } from 'events'; + +/** + * A typed interface of {@link EventEmitter}. + * + * Use the `&` operator to combine multiple event/function pairs into a single event emitter. + * The result needs to be a type and not an interface because of https://github.com/microsoft/TypeScript/issues/16936. + * + * Use the {@link createGenericEventEmitterClass} function to generate an event emitter class with the correct typings + * in case {@link EventEmitter} needs to be extended. + */ +export interface GenericEventEmitter void> + extends EventEmitter { + addListener: (event: TEvent, listener: TFunc) => this; + on: (event: TEvent, listener: TFunc) => this; + once: (event: TEvent, listener: TFunc) => this; + removeListener: (event: TEvent, listener: TFunc) => this; + off: (event: TEvent, listener: TFunc) => this; + removeAllListeners: (event: TEvent) => this; + listeners: (event: TEvent) => TFunc[]; + rawListeners: (event: TEvent) => TFunc[]; + emit: (event: TEvent, ...args: Parameters) => boolean; + listenerCount: (event: TEvent) => number; + prependListener: (event: TEvent, listener: TFunc) => this; + prependOnceListener: (event: TEvent, listener: TFunc) => this; + eventNames: () => TEvent[]; +} + +/** + * Creates a class that is an implementation of {@link EventEmitter} + * but with specific typings based on {@link GenericEventEmitter}. + * Useful in case a class needs to extend {@link EventEmitter} and wants specific internal typings. + */ +export function createGenericEventEmitterClass(): (new() => T) { + return EventEmitter as unknown as new() => T; +} diff --git a/test/unit/storage/MonitoringStore.test.ts b/test/unit/storage/MonitoringStore.test.ts index beb574745..ad57baab8 100644 --- a/test/unit/storage/MonitoringStore.test.ts +++ b/test/unit/storage/MonitoringStore.test.ts @@ -155,14 +155,14 @@ describe('A MonitoringStore', (): void => { expect(source.hasResource).toHaveBeenLastCalledWith({ path: 'http://example.org/foo/bar' }); }); - it('should not emit an extra event when the Activity is not a valid AS value.', async(): Promise => { + it('should not emit an event when the Activity is not a valid AS value.', async(): Promise => { source.addResource = jest.fn().mockResolvedValue(new IdentifierMap([ [{ path: 'http://example.org/path' }, new RepresentationMetadata({ [SOLID_AS.Activity]: 'SomethingRandom' }) ], ])); await store.addResource({ path: 'http://example.org/foo/bar' }, {} as Patch); - expect(changedCallback).toHaveBeenCalledTimes(1); + expect(changedCallback).toHaveBeenCalledTimes(0); expect(createdCallback).toHaveBeenCalledTimes(0); expect(updatedCallback).toHaveBeenCalledTimes(0); expect(deletedCallback).toHaveBeenCalledTimes(0);