add MockRedis testing utility
This commit is contained in:
parent
1d5bc6928f
commit
4142788989
1 changed files with 654 additions and 0 deletions
654
packages/backend/test/misc/MockRedis.ts
Normal file
654
packages/backend/test/misc/MockRedis.ts
Normal file
|
|
@ -0,0 +1,654 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { FakeRedis, ok, type RedisString } from './FakeRedis.js';
|
||||
import type { RedisKey, RedisNumber, RedisValue, RedisCallback, Ok } from './FakeRedis.js';
|
||||
import { TimeService, NativeTimeService } from '@/core/TimeService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
|
||||
export interface MockRedisConstructor {
|
||||
new (timeService?: TimeService): MockRedis;
|
||||
}
|
||||
|
||||
export interface MockRedis extends FakeRedis {
|
||||
/**
|
||||
* Gets a key/value entry from the mock, with metadata.
|
||||
* Returns undefined if no value is stored.
|
||||
*/
|
||||
mockGetEntry(key: RedisKey): MockEntry | undefined;
|
||||
|
||||
/**
|
||||
* Gets a value from the mock, or undefined if no value is stored.
|
||||
*/
|
||||
mockGet(key: RedisKey): RedisValue | undefined;
|
||||
|
||||
/**
|
||||
* Deletes a value from the mock.
|
||||
* Does nothing if the value doesn't exist.
|
||||
*/
|
||||
mockDel(key: RedisKey): void;
|
||||
|
||||
/**
|
||||
* Sets a value in the mock, replacing any prior value.
|
||||
* Expiration, if provided, should be in milliseconds since the Unix epoch.
|
||||
*/
|
||||
mockSet(key: RedisKey, value: RedisValue, expiration?: number | null): void;
|
||||
|
||||
/**
|
||||
* Resets the mock to initial state.
|
||||
*/
|
||||
mockReset(): void;
|
||||
}
|
||||
|
||||
export interface MockEntry {
|
||||
key: RedisKey;
|
||||
value: RedisValue;
|
||||
expiration: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock implementation of Redis that works in-memory and exposes functions to manipulate the values.
|
||||
* Throws on any unsupported operation, and never actually connects.
|
||||
*/
|
||||
export const MockRedis: MockRedisConstructor = createMockRedis();
|
||||
|
||||
function createMockRedis(): MockRedisConstructor {
|
||||
/**
|
||||
* TODO implement pipeline: MULTI/EXEC
|
||||
*/
|
||||
@Injectable()
|
||||
class MockRedis extends FakeRedis implements MockRedis {
|
||||
private readonly timeService: TimeService;
|
||||
private readonly mockData = new Map<string, MockEntry>(); // redis data
|
||||
private readonly mockEvents = new EventManager(); // on/of/once listeners
|
||||
private readonly mockChannels = new Set<string>(); // subscribed pub/sub channels
|
||||
|
||||
constructor(timeService?: TimeService) {
|
||||
super();
|
||||
this.timeService = timeService ?? new NativeTimeService();
|
||||
}
|
||||
|
||||
@bindThis
|
||||
mockGetEntry(key: RedisKey): MockEntry | undefined {
|
||||
const mapped = mapKey(key);
|
||||
|
||||
let entry = this.mockData.get(mapped);
|
||||
if (entry?.expiration && entry.expiration <= this.timeService.now) {
|
||||
this.mockDel(key);
|
||||
entry = undefined;
|
||||
}
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
mockGet(key: RedisKey): RedisValue | undefined {
|
||||
const entry = this.mockGetEntry(key);
|
||||
return entry?.value;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
mockDel(key: RedisKey): void {
|
||||
const mapped = mapKey(key);
|
||||
this.mockData.delete(mapped);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
mockSet(key: RedisKey, value: RedisValue, expiration: number | null = null): void {
|
||||
const mapped = mapKey(key);
|
||||
this.mockData.set(mapped, {
|
||||
expiration,
|
||||
value,
|
||||
key,
|
||||
});
|
||||
}
|
||||
|
||||
@bindThis
|
||||
mockReset(): void {
|
||||
this.mockChannels.clear();
|
||||
this.mockEvents.clear();
|
||||
this.mockData.clear();
|
||||
}
|
||||
|
||||
@bindThis
|
||||
dispose(): void {
|
||||
this.mockEvents.dispose();
|
||||
this.mockReset();
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public once(ev: string, callback: AnyCallback): this {
|
||||
this.mockEvents.once(ev, callback);
|
||||
return this;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public on(ev: string, callback: AnyCallback): this {
|
||||
this.mockEvents.on(ev, callback);
|
||||
return this;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public off(ev: string, callback: AnyCallback): this {
|
||||
this.mockEvents.off(ev, callback);
|
||||
return this;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async subscribe(...args: (RedisString | RedisCallback)[]): Promise<unknown> {
|
||||
const callback = args
|
||||
.find(a => typeof(a) === 'function');
|
||||
const channels = args
|
||||
.filter(a => typeof(a) !== 'function')
|
||||
.flat()
|
||||
.map(s => parseString(s));
|
||||
|
||||
for (const channel of channels) {
|
||||
this.mockChannels.add(channel);
|
||||
}
|
||||
|
||||
callback?.(null, ok);
|
||||
return ok;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async unsubscribe(...args: (RedisString | RedisCallback | undefined)[]): Promise<unknown> {
|
||||
const callback = args
|
||||
.find(a => typeof(a) === 'function');
|
||||
const channels = args
|
||||
.filter(a => typeof(a) !== 'function')
|
||||
.flat()
|
||||
.filter(s => s != null)
|
||||
.map(s => parseString(s));
|
||||
|
||||
for (const channel of channels) {
|
||||
this.mockChannels.delete(channel);
|
||||
}
|
||||
|
||||
callback?.(null, ok);
|
||||
return ok;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async publish(channel: RedisString, message: RedisString, callback?: RedisCallback<number>): Promise<number> {
|
||||
const channelString = parseString(channel);
|
||||
if (!this.mockChannels.has(channelString)) {
|
||||
callback?.(null, 0);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (Buffer.isBuffer(message)) {
|
||||
channel = Buffer.from(channel);
|
||||
this.mockEvents.emit('messageBuffer', channel, message);
|
||||
} else {
|
||||
message = parseString(message);
|
||||
this.mockEvents.emit('message', channelString, message);
|
||||
}
|
||||
|
||||
callback?.(null, 1);
|
||||
return 1;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async get(key: RedisKey, callback?: RedisCallback<string | null>): Promise<string | null> {
|
||||
const value = this.mockGet(key);
|
||||
|
||||
if (value != null && typeof(value) !== 'string') {
|
||||
const err = new Error('get failed: cannot GET a non-string value');
|
||||
callback?.(err);
|
||||
throw err;
|
||||
}
|
||||
|
||||
callback?.(null, value ?? null);
|
||||
return value ?? null;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async getBuffer(key: RedisKey, callback?: RedisCallback<Buffer | null>): Promise<Buffer | null> {
|
||||
const value = this.mockGet(key);
|
||||
|
||||
if (value != null && !Buffer.isBuffer(value)) {
|
||||
const err = new Error('getBuffer failed: cannot GET a non-string value');
|
||||
callback?.(err);
|
||||
throw err;
|
||||
}
|
||||
|
||||
callback?.(null, value ?? null);
|
||||
return value ?? null;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async getDel(key: RedisKey, callback?: RedisCallback<string | null>): Promise<string | null> {
|
||||
const value = this.mockGet(key);
|
||||
|
||||
if (value != null && typeof(value) !== 'string') {
|
||||
const err = new Error('getDel failed: cannot GETDEL a non-string value');
|
||||
callback?.(err);
|
||||
throw err;
|
||||
}
|
||||
|
||||
this.mockDel(key);
|
||||
|
||||
callback?.(null, value ?? null);
|
||||
return value ?? null;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async getDelBuffer(key: RedisKey, callback?: RedisCallback<Buffer | null>): Promise<Buffer | null> {
|
||||
const value = this.mockGet(key);
|
||||
|
||||
if (value != null && !Buffer.isBuffer(value)) {
|
||||
const err = new Error('getDelBuffer failed: cannot GETDEL a non-string value');
|
||||
callback?.(err);
|
||||
throw err;
|
||||
}
|
||||
|
||||
this.mockDel(key);
|
||||
|
||||
callback?.(null, value ?? null);
|
||||
return value ?? null;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async getSet(key: RedisKey, newValue: RedisValue, callback?: RedisCallback<string | null>): Promise<string | null> {
|
||||
const oldValue = this.mockGet(key);
|
||||
|
||||
if (oldValue != null && typeof(oldValue) !== 'string') {
|
||||
const err = new Error('getSet failed: cannot GETSET a non-string value');
|
||||
callback?.(err);
|
||||
throw err;
|
||||
}
|
||||
|
||||
this.mockSet(key, newValue);
|
||||
|
||||
callback?.(null, oldValue ?? null);
|
||||
return oldValue ?? null;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async getSetBuffer(key: RedisKey, newValue: RedisValue, callback?: RedisCallback<Buffer | null>): Promise<Buffer | null> {
|
||||
const oldValue = this.mockGet(key);
|
||||
|
||||
if (oldValue != null && !Buffer.isBuffer(oldValue)) {
|
||||
const err = new Error('getSetBuffer failed: cannot GETSET a non-string value');
|
||||
callback?.(err);
|
||||
throw err;
|
||||
}
|
||||
|
||||
this.mockSet(key, newValue);
|
||||
|
||||
callback?.(null, oldValue ?? null);
|
||||
return oldValue ?? null;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async del(...args: (RedisKey | RedisKey[] | RedisCallback<number>)[]): Promise<number> {
|
||||
const callback = args.find(a => typeof(a) === 'function');
|
||||
const keys = args.filter(a => typeof(a) !== 'function').flat();
|
||||
|
||||
let total = 0;
|
||||
for (const key of keys) {
|
||||
const entry = this.mockGet(key);
|
||||
if (entry) {
|
||||
total++;
|
||||
this.mockDel(key);
|
||||
}
|
||||
}
|
||||
|
||||
callback?.(null, total);
|
||||
return total;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async incr(key: RedisKey, callback?: RedisCallback<number>): Promise<number> {
|
||||
return await this.incrCommon(key, 1, true, 'incr', callback);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async incrby(key: RedisKey, increment: RedisNumber, callback?: RedisCallback<number>): Promise<number> {
|
||||
return await this.incrCommon(key, increment, true, 'incrby', callback);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async decr(key: RedisKey, callback?: RedisCallback<number>): Promise<number> {
|
||||
return await this.incrCommon(key, 1, false, 'decr', callback);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async decrby(key: RedisKey, increment: RedisNumber, callback?: RedisCallback<number>): Promise<number> {
|
||||
return await this.incrCommon(key, increment, false, 'decrby', callback);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async incrCommon(key: RedisKey, increment: RedisNumber, add: boolean, func: string, callback?: RedisCallback<number>): Promise<number> {
|
||||
// Parse the increment
|
||||
const inc = parseNumber(increment);
|
||||
if (inc == null) {
|
||||
const err = new Error(`${func} failed: cannot parse increment as integer`);
|
||||
callback?.(err);
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Extract and verify the value
|
||||
const entry = this.mockGetEntry(key);
|
||||
let value = entry != null ? parseNumber(entry.value) : 0;
|
||||
if (value == null) {
|
||||
const err = new Error(`${func} failed: cannot ${func.toUpperCase()} a non-number value`);
|
||||
callback?.(err);
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Apply the increment
|
||||
if (add) {
|
||||
value += inc;
|
||||
} else {
|
||||
value -= inc;
|
||||
}
|
||||
|
||||
// Update, but preserve expiration
|
||||
this.mockSet(key, value, entry?.expiration);
|
||||
|
||||
callback?.(null, value);
|
||||
return value;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async setex(key: RedisKey, seconds: RedisNumber, value: RedisValue, callback?: RedisCallback<Ok>): Promise<Ok> {
|
||||
await this.set(key, value, 'EX', seconds);
|
||||
callback?.(null, ok);
|
||||
return ok;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async setnx(key: RedisKey, value: RedisValue, callback?: RedisCallback<number>): Promise<number> {
|
||||
const ok = await this.set(key, value, 'NX');
|
||||
callback?.(null, ok ? 1 : 0);
|
||||
return ok ? 1 : 0;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
// @ts-expect-error This comes from collapsing all the overload signatures, but it's fine.
|
||||
public async set(
|
||||
key: RedisKey,
|
||||
value: RedisValue,
|
||||
op1?: SetOp1 | RedisCallback<Ok | string | null>,
|
||||
op2?: SetOp2 | SetArg | RedisCallback<Ok | string | null>,
|
||||
op3?: SetOp3 | SetArg | RedisCallback<Ok | string | null>,
|
||||
op4?: SetArg | RedisCallback<Ok | string | null>,
|
||||
op5?: RedisCallback<Ok | string | null>,
|
||||
): Promise<Ok | string | null> {
|
||||
const entry = this.mockGetEntry(key);
|
||||
|
||||
// Parse ops
|
||||
const { nx, ex, get, cb, err } = this._parseSetOps(entry ?? null, [op1, op2, op3, op4, op5]);
|
||||
|
||||
// Additional error from the "GET" flag
|
||||
if (get && entry != null && typeof(entry.value) !== 'string') {
|
||||
err.push(new Error('set failed: cannot GET a non-string value.'));
|
||||
}
|
||||
|
||||
// Abort on errors
|
||||
if (err.length > 1) {
|
||||
const agg = new AggregateError(err, 'set failed: see "errors" property for details.');
|
||||
if (cb) cb(agg);
|
||||
throw agg;
|
||||
} else if (err.length > 0) {
|
||||
if (cb) cb(err[0]);
|
||||
throw err[0];
|
||||
}
|
||||
|
||||
// Emulate the "NX" and "XX" flags
|
||||
const nxLock =
|
||||
(nx === true && entry != null) || // NX - skip if the key already exists (Never eXchange)
|
||||
(nx === false && entry == null); // XX - skip the key *doesn't* exist (eXclusively eXchange)
|
||||
|
||||
// Compute return value for the operation
|
||||
const ret = get
|
||||
? entry // Return the previous value or null for GET
|
||||
? parseString(entry.value)
|
||||
: null
|
||||
: nxLock
|
||||
? null // Return null if locked out by NX or XX
|
||||
: ok; // Otherwise return ok, even if we don't set it.
|
||||
|
||||
// Write *after* we compute the return value!
|
||||
const doWrite = !nxLock;
|
||||
if (doWrite) {
|
||||
this.mockSet(key, value, ex);
|
||||
}
|
||||
|
||||
// Return the results
|
||||
if (cb) cb(null, ret);
|
||||
return ret;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private _parseSetOps(entry: MockEntry | null, ops: SetOp[]) {
|
||||
const err: Error[] = [];
|
||||
let ex: number | null | undefined;
|
||||
let nx: boolean | null = null;
|
||||
let get = false;
|
||||
let cb: RedisCallback<Ok | string | null> | null = null;
|
||||
|
||||
// Slide through it til we reach the end
|
||||
let nextIsParam = false;
|
||||
for (let i = 0; i < ops.length && ops[i] != null; i++) {
|
||||
// This is set when one of the ops consumed the next token as an argument.
|
||||
if (nextIsParam) {
|
||||
nextIsParam = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
const opRaw = ops[i];
|
||||
const op = typeof(opRaw) === 'function' ? opRaw : parseString(opRaw);
|
||||
|
||||
const argRaw = ops[i + 1];
|
||||
const arg = typeof(argRaw) === 'function' ? argRaw : parseNumber(argRaw);
|
||||
|
||||
if (typeof(op) === 'function') {
|
||||
cb = op as RedisCallback<Ok | string | null>;
|
||||
} else if (op === 'KEEPTTL') {
|
||||
ex = entry?.expiration;
|
||||
} else if (op === 'GET') {
|
||||
get = true;
|
||||
} else if (op === 'NX') {
|
||||
nx = true;
|
||||
} else if (op === 'XX') {
|
||||
nx = false;
|
||||
} else if (op === 'EX') {
|
||||
nextIsParam = true;
|
||||
if (arg == null) {
|
||||
err.push(new Error('Missing required argument for set "EX" parameter'));
|
||||
} else if (typeof(arg) !== 'number') {
|
||||
err.push(new Error('Invalid argument for set "EX" parameter: must be a number'));
|
||||
} else if (arg < 0) {
|
||||
err.push(new Error('Invalid argument for set "EX" parameter: must be a positive integer'));
|
||||
} else {
|
||||
ex = this.timeService.now + (arg * 1000);
|
||||
}
|
||||
} else if (op === 'PX') {
|
||||
nextIsParam = true;
|
||||
if (arg == null) {
|
||||
err.push(new Error('Missing required argument for set "EX" parameter'));
|
||||
} else if (typeof(arg) !== 'number') {
|
||||
err.push(new Error('Invalid argument for set "EX" parameter: must be a number'));
|
||||
} else if (arg < 0) {
|
||||
err.push(new Error('Invalid argument for set "EX" parameter: must be a positive integer'));
|
||||
} else {
|
||||
ex = this.timeService.now + arg;
|
||||
}
|
||||
} else if (op === 'EXAT') {
|
||||
nextIsParam = true;
|
||||
if (arg == null) {
|
||||
err.push(new Error('Missing required argument for set "EX" parameter'));
|
||||
} else if (typeof(arg) !== 'number') {
|
||||
err.push(new Error('Invalid argument for set "EX" parameter: must be a number'));
|
||||
} else if (arg < 0) {
|
||||
err.push(new Error('Invalid argument for set "EX" parameter: must be a positive integer'));
|
||||
} else {
|
||||
ex = arg * 1000;
|
||||
}
|
||||
} else if (op === 'PXAT') {
|
||||
nextIsParam = true;
|
||||
if (arg == null) {
|
||||
err.push(new Error('Missing required argument for set "EX" parameter'));
|
||||
} else if (typeof(arg) !== 'number') {
|
||||
err.push(new Error('Invalid argument for set "EX" parameter: must be a number'));
|
||||
} else if (arg < 0) {
|
||||
err.push(new Error('Invalid argument for set "EX" parameter: must be a positive integer'));
|
||||
} else {
|
||||
ex = arg;
|
||||
}
|
||||
} else {
|
||||
err.push(new Error(`Unknown parameter for set: "${op}"`));
|
||||
}
|
||||
}
|
||||
|
||||
return { ex, nx, get, cb, err };
|
||||
}
|
||||
}
|
||||
|
||||
return MockRedis as unknown as MockRedisConstructor;
|
||||
}
|
||||
|
||||
function mapKey(key: RedisKey): string {
|
||||
const prefix = Buffer.isBuffer(key) ? 'b' : 's';
|
||||
const mapped = parseString(key);
|
||||
return `${prefix}:${mapped}`;
|
||||
}
|
||||
|
||||
function parseNumber(value: RedisValue | undefined): number | undefined;
|
||||
function parseNumber(value: RedisValue | null | undefined): number | null | undefined;
|
||||
|
||||
function parseNumber(value: RedisValue | null | undefined): number | null | undefined {
|
||||
if (value == null) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof(value) !== 'number') {
|
||||
value = parseString(value);
|
||||
value = parseInt(value);
|
||||
}
|
||||
|
||||
if (!Number.isSafeInteger(value)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (Number.isNaN(value)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseString(value: RedisValue): string;
|
||||
function parseString(value: RedisValue | null): string | null;
|
||||
function parseString(value: RedisValue | undefined): string | undefined;
|
||||
function parseString(value: RedisValue | null | undefined): string | null | undefined;
|
||||
|
||||
function parseString(value: RedisValue | null | undefined): string | null | undefined {
|
||||
if (value == null) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (Buffer.isBuffer(value)) {
|
||||
return value.toString('utf-8');
|
||||
}
|
||||
|
||||
return String(value);
|
||||
}
|
||||
|
||||
type SetOp = SetOp1 | SetOp2 | SetOp3 | SetArg | undefined;
|
||||
type SetOp1 = SetOp2 | 'NX' | 'XX';
|
||||
type SetOp2 = SetOp3 | 'GET';
|
||||
type SetOp3 = 'EX' | 'PX' | 'EXAT' | 'PXAT' | 'KEEPTTL';
|
||||
type SetArg = RedisNumber | RedisCallback<Ok | string | null>;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type AnyCallback = (...args: any[]) => any;
|
||||
|
||||
class EventManager {
|
||||
private readonly groups = new Map<string, EventGroup>();
|
||||
|
||||
public emit(ev: string, ...args: unknown[]) {
|
||||
this.groups.get(ev)?.emit(args);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public once(ev: string, callback: AnyCallback) {
|
||||
this.on(ev, (...args: unknown[]) => {
|
||||
this.off(ev, callback);
|
||||
callback(...args);
|
||||
});
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public on(ev: string, callback: AnyCallback) {
|
||||
this.makeEvent(ev).add(callback);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public off(ev: string, callback: AnyCallback) {
|
||||
this.groups.get(ev)?.remove(callback);
|
||||
}
|
||||
|
||||
private makeEvent(ev: string): EventGroup {
|
||||
let group = this.groups.get(ev);
|
||||
if (!group) {
|
||||
group = new EventGroup(ev);
|
||||
this.groups.set(ev, group);
|
||||
}
|
||||
return group;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public clear() {
|
||||
for (const group of this.groups.values()) {
|
||||
group.clear();
|
||||
}
|
||||
|
||||
this.groups.clear();
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public dispose() {
|
||||
for (const group of this.groups.values()) {
|
||||
group.dispose();
|
||||
}
|
||||
|
||||
this.clear();
|
||||
}
|
||||
}
|
||||
|
||||
class EventGroup {
|
||||
private readonly listeners = new Set<AnyCallback>();
|
||||
|
||||
constructor(
|
||||
public readonly ev: string,
|
||||
) {}
|
||||
|
||||
public add(listener: AnyCallback): void {
|
||||
this.listeners.add(listener);
|
||||
}
|
||||
|
||||
public remove(listener: AnyCallback): void {
|
||||
this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
public emit(...args: unknown[]): void {
|
||||
for (const listener of this.listeners) {
|
||||
listener(...args);
|
||||
}
|
||||
}
|
||||
|
||||
public clear() {
|
||||
this.listeners.clear();
|
||||
}
|
||||
|
||||
public dispose() {
|
||||
this.clear();
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue