feat: Create a GenericEventEmitter interface

This allows us to have typed EventEmitters.
This commit is contained in:
Joachim Van Herwegen
2022-09-29 16:42:37 +02:00
parent 3328f8dea6
commit 764ce3cc28
6 changed files with 72 additions and 7 deletions

View File

@@ -1,6 +1,7 @@
[
"AccessMap",
"Adapter",
"BaseActivityEmitter",
"BaseHttpError",
"BasicConditions",
"BasicRepresentation",
@@ -9,6 +10,7 @@
"Dict",
"Error",
"EventEmitter",
"GenericEventEmitter",
"HashMap",
"HttpErrorOptions",
"HttpResponse",

View File

@@ -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';

View File

@@ -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<typeof AS>) => void> &
GenericEventEmitter<VocabularyValue<typeof AS>, (target: ResourceIdentifier) => void>;
/**
* A class implementation of {@link ActivityEmitter}.
*/
// eslint-disable-next-line @typescript-eslint/naming-convention
export const BaseActivityEmitter = createGenericEventEmitterClass<ActivityEmitter>();

View File

@@ -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<string> = 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<T extends ResourceStore = ResourceStore>
extends EventEmitter implements ResourceStore {
extends BaseActivityEmitter implements ResourceStore {
private readonly source: T;
public constructor(source: T) {
@@ -55,12 +56,16 @@ export class MonitoringStore<T extends ResourceStore = ResourceStore>
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)));
}
}

View File

@@ -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<TEvent extends string | symbol, TFunc extends (...args: any[]) => 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<TFunc>) => 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<T extends EventEmitter>(): (new() => T) {
return EventEmitter as unknown as new() => T;
}

View File

@@ -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<void> => {
it('should not emit an event when the Activity is not a valid AS value.', async(): Promise<void> => {
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);