From c6b7d5ab32484498bf6caa81d9efb29d90ca235f Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sun, 8 Feb 2026 03:08:07 +0000 Subject: [PATCH] =?UTF-8?q?=F0=9F=A7=B9=20Centralize=20SimpleEventEmitter?= =?UTF-8?q?=20class?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 <53631862+PR0M3TH3AN@users.noreply.github.com> --- js/services/authService.js | 44 +-------------------- js/services/moderationService.js | 51 +----------------------- js/services/nostrService.js | 51 +----------------------- js/services/playbackService.js | 43 +------------------- js/utils/eventEmitter.js | 65 +++++++++++++++++++++++++++++++ tests/utils/eventEmitter.test.mjs | 53 +++++++++++++++++++++++++ 6 files changed, 126 insertions(+), 181 deletions(-) create mode 100644 js/utils/eventEmitter.js create mode 100644 tests/utils/eventEmitter.test.mjs diff --git a/js/services/authService.js b/js/services/authService.js index 0a6525a9..0fac9d5a 100644 --- a/js/services/authService.js +++ b/js/services/authService.js @@ -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) => { diff --git a/js/services/moderationService.js b/js/services/moderationService.js index 8e47fef3..77b8d6c5 100644 --- a/js/services/moderationService.js +++ b/js/services/moderationService.js @@ -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(); diff --git a/js/services/nostrService.js b/js/services/nostrService.js index 5d7dfe7e..b0bee9e9 100644 --- a/js/services/nostrService.js +++ b/js/services/nostrService.js @@ -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; diff --git a/js/services/playbackService.js b/js/services/playbackService.js index 800bde0c..fa8f76d7 100644 --- a/js/services/playbackService.js +++ b/js/services/playbackService.js @@ -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; diff --git a/js/utils/eventEmitter.js b/js/utils/eventEmitter.js new file mode 100644 index 00000000..d0abe3be --- /dev/null +++ b/js/utils/eventEmitter.js @@ -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); + } + } + } + } + } +} diff --git a/tests/utils/eventEmitter.test.mjs b/tests/utils/eventEmitter.test.mjs new file mode 100644 index 00000000..da73ac5f --- /dev/null +++ b/tests/utils/eventEmitter.test.mjs @@ -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"); + }); +});