🧹 Centralize SimpleEventEmitter class

Extract duplicated SimpleEventEmitter class from multiple service files into a shared utility file `js/utils/eventEmitter.js`.

- Created `js/utils/eventEmitter.js` with unified implementation.
- Refactored `AuthService`, `NostrService`, `ModerationService`, and `PlaybackService` to use the shared class.
- Added support for constructor-defined context strings for improved error logging.
- Added comprehensive unit tests for `SimpleEventEmitter`.
- Verified changes with targeted service tests.

Co-authored-by: PR0M3TH3AN <[email protected]>
This commit is contained in:
google-labs-jules[bot]andPR0M3TH3AN committed 2026-02-08 03:08:07 +00:00
1 parent 84485e5607
commit c6b7d5ab32
6 files changed
+126 -181

No files matched your search

+2 -42
View File
@@ -7,6 +7,7 @@ import {
getCurrentUserNpub,
} from "../state/appState.js";
import { userLogger } from "../utils/logger.js";
import { SimpleEventEmitter } from "../utils/eventEmitter.js";
import { requestDefaultExtensionPermissions } from "../nostr/defaultClient.js";
import {
getSavedProfiles,
@@ -27,47 +28,6 @@ import getDefaultAuthProvider, {
providers as defaultAuthProviders,
} from "./authProviders/index.js";
class SimpleEventEmitter {
constructor(logger = null) {
this.logger = typeof logger === "function" ? logger : null;
this.listeners = new Map();
}
on(eventName, handler) {
if (typeof handler !== "function") {
return () => {};
}
if (!this.listeners.has(eventName)) {
this.listeners.set(eventName, new Set());
}
const handlers = this.listeners.get(eventName);
handlers.add(handler);
return () => {
handlers.delete(handler);
if (!handlers.size) {
this.listeners.delete(eventName);
}
};
}
emit(eventName, detail) {
const handlers = this.listeners.get(eventName);
if (!handlers || !handlers.size) {
return;
}
for (const handler of Array.from(handlers)) {
try {
handler(detail);
} catch (error) {
if (this.logger) {
this.logger(`AuthService listener for "${eventName}" threw`, error);
}
}
}
}
}
const HEX64_REGEX = /^[0-9a-f]{64}$/i;
const FALLBACK_PROFILE = {
name: "Unknown",
@@ -116,7 +76,7 @@ export default class AuthService {
} catch (logError) {
userLogger.warn("[AuthService] logger threw", logError);
}
});
}, "AuthService");
if (typeof getAuthProvider === "function") {
this.resolveAuthProvider = (providerId) => {
+2 -49
View File
@@ -8,6 +8,7 @@ import { accessControl } from "../accessControl.js";
import { userBlocks, USER_BLOCK_EVENTS } from "../userBlocks.js";
import { buildReportEvent } from "../nostrEventSchemas.js";
import logger from "../utils/logger.js";
import { SimpleEventEmitter } from "../utils/eventEmitter.js";
const AUTOPLAY_TRUST_THRESHOLD = 1;
const BLUR_TRUST_THRESHOLD = 1;
@@ -25,54 +26,6 @@ function normalizeUserLogger(candidate) {
return logger.user;
}
class SimpleEventEmitter {
constructor(logHandler = null) {
this.listeners = new Map();
this.logHandler = typeof logHandler === "function" ? logHandler : null;
}
on(eventName, handler) {
if (typeof eventName !== "string" || typeof handler !== "function") {
return () => {};
}
if (!this.listeners.has(eventName)) {
this.listeners.set(eventName, new Set());
}
const handlers = this.listeners.get(eventName);
handlers.add(handler);
return () => {
handlers.delete(handler);
if (!handlers.size) {
this.listeners.delete(eventName);
}
};
}
emit(eventName, detail) {
const handlers = this.listeners.get(eventName);
if (!handlers || !handlers.size) {
return;
}
for (const handler of Array.from(handlers)) {
try {
handler(detail);
} catch (error) {
if (this.logHandler) {
try {
this.logHandler(`moderationService listener for "${eventName}" threw`, error);
} catch (logError) {
logger.user.warn("[moderationService] listener logger threw", logError);
}
}
}
}
}
}
function normalizeLogger(candidate) {
if (typeof candidate === "function") {
return candidate;
@@ -549,7 +502,7 @@ export class ModerationService {
} catch (logError) {
logger.user.warn("[moderationService] logger threw", logError);
}
});
}, "moderationService");
this.userBlockUnsubscribe = null;
this.userBlockRefreshQueue = Promise.resolve();
+2 -49
View File
@@ -3,6 +3,7 @@ import { convertEventToVideo } from "../nostr/index.js";
import { accessControl } from "../accessControl.js";
import { ALLOW_NSFW_CONTENT } from "../config.js";
import { devLogger, userLogger } from "../utils/logger.js";
import { SimpleEventEmitter } from "../utils/eventEmitter.js";
import { LRUCache } from "../utils/lruCache.js";
import { describeAttachment, extractAttachmentsFromMessage } from "../attachments/attachmentUtils.js";
import moderationService from "./moderationService.js";
@@ -76,54 +77,6 @@ function persistFilteredVideos(videos) {
}
}
class SimpleEventEmitter {
constructor(logger = null) {
this.logger = typeof logger === "function" ? logger : null;
this.listeners = new Map();
}
on(eventName, handler) {
if (typeof handler !== "function") {
return () => {};
}
if (!this.listeners.has(eventName)) {
this.listeners.set(eventName, new Set());
}
const handlers = this.listeners.get(eventName);
handlers.add(handler);
return () => {
handlers.delete(handler);
if (!handlers.size) {
this.listeners.delete(eventName);
}
};
}
emit(eventName, detail) {
const handlers = this.listeners.get(eventName);
if (!handlers || !handlers.size) {
return;
}
for (const handler of Array.from(handlers)) {
try {
handler(detail);
} catch (error) {
if (this.logger) {
try {
this.logger(`nostrService listener for "${eventName}" threw`, error);
} catch (logError) {
userLogger.warn("[nostrService] listener logger threw", logError);
}
}
}
}
}
}
function normalizeLogger(logger) {
if (typeof logger === "function") {
return logger;
@@ -547,7 +500,7 @@ export class NostrService {
} catch (logError) {
userLogger.warn("[nostrService] logger threw", logError);
}
});
}, "nostrService");
this.videosMap = null;
this.videosByAuthorIndex = null;
this.authorIndexDirty = false;
+2 -41
View File
@@ -1,4 +1,5 @@
import { userLogger } from "../utils/logger.js";
import { SimpleEventEmitter } from "../utils/eventEmitter.js";
import { PLAYBACK_START_TIMEOUT } from "../constants.js";
// js/services/playbackService.js
@@ -19,46 +20,6 @@ import { PLAYBACK_START_TIMEOUT } from "../constants.js";
* old session is cancelled and doesn't overwrite the new one.
*/
class SimpleEventEmitter {
constructor(logger = null) {
this.logger = typeof logger === "function" ? logger : null;
this.listeners = new Map();
}
on(eventName, handler) {
if (typeof handler !== "function") {
return () => {};
}
if (!this.listeners.has(eventName)) {
this.listeners.set(eventName, new Set());
}
const handlers = this.listeners.get(eventName);
handlers.add(handler);
return () => {
handlers.delete(handler);
if (!handlers.size) {
this.listeners.delete(eventName);
}
};
}
emit(eventName, detail) {
const handlers = this.listeners.get(eventName);
if (!handlers || !handlers.size) {
return;
}
for (const handler of Array.from(handlers)) {
try {
handler(detail);
} catch (err) {
if (this.logger) {
this.logger("[PlaybackService] Listener error", err);
}
}
}
}
}
const HOSTED_URL_SUCCESS_MESSAGE = "✅ Streaming from hosted URL";
const DEFAULT_UNSUPPORTED_BTITH_MESSAGE =
"This magnet link is missing a compatible BitTorrent v1 info hash.";
@@ -288,7 +249,7 @@ export class PlaybackService {
*/
class PlaybackSession extends SimpleEventEmitter {
constructor(service, options = {}) {
super((message, ...args) => service.log(message, ...args));
super((message, ...args) => service.log(message, ...args), "PlaybackService");
this.service = service;
this.options = options;
this.watchdogCleanup = null;
+65
View File
@@ -0,0 +1,65 @@
/**
* A lightweight event emitter used by services to communicate state changes
* and other events to the UI and other parts of the application.
*/
export class SimpleEventEmitter {
/**
* @param {Function|null} logger - Optional logger function for error reporting.
* @param {string} context - Optional context string for error messages.
*/
constructor(logger = null, context = "SimpleEventEmitter") {
this.logger = typeof logger === "function" ? logger : null;
this.context = typeof context === "string" ? context : "SimpleEventEmitter";
this.listeners = new Map();
}
/**
* Registers a handler for a specific event.
* @param {string} eventName
* @param {Function} handler
* @returns {Function} Unsubscribe function.
*/
on(eventName, handler) {
if (typeof handler !== "function") {
return () => {};
}
if (!this.listeners.has(eventName)) {
this.listeners.set(eventName, new Set());
}
const handlers = this.listeners.get(eventName);
handlers.add(handler);
return () => {
handlers.delete(handler);
if (!handlers.size) {
this.listeners.delete(eventName);
}
};
}
/**
* Emits an event to all registered handlers.
* @param {string} eventName
* @param {any} detail
*/
emit(eventName, detail) {
const handlers = this.listeners.get(eventName);
if (!handlers || !handlers.size) {
return;
}
for (const handler of Array.from(handlers)) {
try {
handler(detail);
} catch (error) {
if (this.logger) {
try {
this.logger(`${this.context} listener for "${eventName}" threw`, error);
} catch (logError) {
// Fallback if the provided logger itself throws
console.warn(`[${this.context}] listener logger threw`, logError);
}
}
}
}
}
}
+53
View File
@@ -0,0 +1,53 @@
import { describe, test, mock } from "node:test";
import assert from "node:assert/strict";
import { SimpleEventEmitter } from "../../js/utils/eventEmitter.js";
describe("SimpleEventEmitter", () => {
test("should register and emit events", () => {
const emitter = new SimpleEventEmitter();
const handler = mock.fn();
emitter.on("test", handler);
emitter.emit("test", { data: 123 });
assert.strictEqual(handler.mock.callCount(), 1);
assert.deepStrictEqual(handler.mock.calls[0].arguments[0], { data: 123 });
});
test("should support multiple handlers", () => {
const emitter = new SimpleEventEmitter();
const handler1 = mock.fn();
const handler2 = mock.fn();
emitter.on("test", handler1);
emitter.on("test", handler2);
emitter.emit("test", "foo");
assert.strictEqual(handler1.mock.callCount(), 1);
assert.strictEqual(handler2.mock.callCount(), 1);
});
test("should unsubscribe correctly", () => {
const emitter = new SimpleEventEmitter();
const handler = mock.fn();
const unsub = emitter.on("test", handler);
unsub();
emitter.emit("test", "foo");
assert.strictEqual(handler.mock.callCount(), 0);
});
test("should handle errors in handlers and use logger", () => {
const logger = mock.fn();
const emitter = new SimpleEventEmitter(logger, "TestContext");
const errorHandler = () => { throw new Error("fail"); };
emitter.on("error-event", errorHandler);
emitter.emit("error-event", "some-detail");
assert.strictEqual(logger.mock.callCount(), 1);
assert.match(logger.mock.calls[0].arguments[0], /TestContext listener for "error-event" threw/);
assert.strictEqual(logger.mock.calls[0].arguments[1].message, "fail");
});
});