diff --git a/README.md b/README.md index ccb750b..5f45916 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,7 @@ - [.callHook(eventName, ...args)](#callhookeventname-args) - [.beforeHook(eventName, ...args)](#beforehookeventname-args) - [.afterHook(eventName, ...args)](#afterhookeventname-args) + - [.hookSync(eventName, ...args)](#hooksync-eventname-args) - [.hooks](#hooks) - [.getHooks(eventName)](#gethookseventname) - [.clearHooks(eventName)](#clearhookeventname) @@ -801,6 +802,44 @@ class MyClass extends Hookified { } ``` +## .hookSync(eventName, ...args) + +Run a hook event synchronously. Async handlers (functions declared with `async` keyword) are silently skipped and only synchronous handlers are executed. + +> **Note:** The `.hook()` method is preferred as it executes both sync and async functions. Use `.hookSync()` only when you specifically need synchronous execution. + +```javascript +import { Hookified } from 'hookified'; + +class MyClass extends Hookified { + constructor() { + super(); + } + + myMethodWithSyncHooks() { + let data = { some: 'data' }; + // Only synchronous handlers will execute + this.hookSync('before:myMethod', data); + + return data; + } +} + +const myClass = new MyClass(); + +// This sync handler will execute +myClass.onHook('before:myMethod', (data) => { + data.some = 'modified'; +}); + +// This async handler will be silently skipped +myClass.onHook('before:myMethod', async (data) => { + data.some = 'will not run'; +}); + +myClass.myMethodWithSyncHooks(); // Only sync handler runs +``` + ## .hooks Get all hooks. diff --git a/src/index.ts b/src/index.ts index 6ba796c..87c4690 100644 --- a/src/index.ts +++ b/src/index.ts @@ -328,6 +328,44 @@ export class Hookified extends Eventified { } } + /** + * Calls all synchronous handlers for a specific event. + * Async handlers (declared with `async` keyword) are silently skipped. + * + * Note: The `hook` method is preferred as it executes both sync and async functions. + * Use `hookSync` only when you specifically need synchronous execution. + * @param {string} event + * @param {T[]} arguments_ + * @returns {void} + */ + public hookSync(event: string, ...arguments_: T[]): void { + this.validateHookName(event); + if (!this.checkDeprecatedHook(event)) { + return; + } + + const eventHandlers = this._hooks.get(event); + if (eventHandlers) { + for (const handler of eventHandlers) { + // Skip async functions silently + if (handler.constructor.name === "AsyncFunction") { + continue; + } + + try { + handler(...arguments_); + } catch (error) { + const message = `${event}: ${(error as Error).message}`; + this.emit("error", new Error(message)); + + if (this._throwOnHookError) { + throw new Error(message); + } + } + } + } + } + /** * Prepends the word `before` to your hook. Example is event is `test`, the before hook is `before:test`. * @param {string} event - The event name diff --git a/test/index.test.ts b/test/index.test.ts index 3fb56ae..95d39bc 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -1678,4 +1678,176 @@ describe("Hookified", () => { expect(hookified.getHooks("oldHook")).toBeUndefined(); }); }); + + describe("hookSync", () => { + test("should execute synchronous handlers", () => { + const hookified = new Hookified(); + const data = { key: "value" }; + + const handler = (data: any) => { + data.key = "modified"; + }; + + hookified.onHook("event", handler); + hookified.hookSync("event", data); + expect(data.key).toBe("modified"); + }); + + test("should skip async handlers silently", () => { + const hookified = new Hookified(); + const executionOrder: string[] = []; + + const syncHandler = () => { + executionOrder.push("sync"); + }; + + const asyncHandler = async () => { + executionOrder.push("async"); + }; + + hookified.onHook("event", syncHandler); + hookified.onHook("event", asyncHandler); + hookified.onHook("event", syncHandler); + + hookified.hookSync("event"); + + // Only sync handlers should execute + expect(executionOrder).toEqual(["sync", "sync"]); + }); + + test("should execute handlers in order", () => { + const hookified = new Hookified(); + const executionOrder: number[] = []; + + hookified.onHook("event", () => executionOrder.push(1)); + hookified.onHook("event", () => executionOrder.push(2)); + hookified.onHook("event", () => executionOrder.push(3)); + + hookified.hookSync("event"); + + expect(executionOrder).toEqual([1, 2, 3]); + }); + + test("should emit error event on handler error", () => { + const hookified = new Hookified(); + let errorMessage: string | undefined; + + hookified.on("error", (error: Error) => { + errorMessage = error.message; + }); + + const handler = () => { + throw new Error("sync error"); + }; + + hookified.onHook("event", handler); + hookified.hookSync("event"); + + expect(errorMessage).toBe("event: sync error"); + }); + + test("should throw error when throwOnHookError is true", () => { + const hookified = new Hookified({ throwOnHookError: true }); + + const handler = () => { + throw new Error("sync error"); + }; + + hookified.onHook("event", handler); + + expect(() => hookified.hookSync("event")).toThrow("event: sync error"); + }); + + test("should validate hook name when enforceBeforeAfter is true", () => { + const hookified = new Hookified({ enforceBeforeAfter: true }); + + expect(() => hookified.hookSync("invalidName")).toThrow( + 'Hook event "invalidName" must start with "before" or "after" when enforceBeforeAfter is enabled', + ); + }); + + test("should respect deprecated hooks setting", () => { + const deprecatedHooks = new Map([["oldHook", "Use newHook instead"]]); + const hookified = new Hookified({ + deprecatedHooks, + allowDeprecated: false, + }); + const handler = vi.fn(); + + hookified.onHook("oldHook", handler); + hookified.hookSync("oldHook"); + + expect(handler).not.toHaveBeenCalled(); + }); + + test("should pass multiple arguments to handlers", () => { + const hookified = new Hookified(); + const data1 = { key: "value1" }; + const data2 = { key: "value2" }; + let capturedArgs: any[] = []; + + const handler = (...args: any[]) => { + capturedArgs = args; + }; + + hookified.onHook("event", handler); + hookified.hookSync("event", data1, data2); + + expect(capturedArgs).toEqual([data1, data2]); + }); + + test("should handle no handlers gracefully", () => { + const hookified = new Hookified(); + + // Should not throw + expect(() => hookified.hookSync("nonexistent")).not.toThrow(); + }); + + test("should detect async function correctly", () => { + const hookified = new Hookified(); + const results: string[] = []; + + // Regular function - should execute + hookified.onHook("event", () => { + results.push("regular"); + }); + + // Arrow function - should execute + hookified.onHook("event", () => { + results.push("arrow"); + }); + + // Async function - should skip + hookified.onHook("event", async () => { + results.push("async-regular"); + }); + + // Async arrow function - should skip + hookified.onHook("event", async () => { + results.push("async-arrow"); + }); + + hookified.hookSync("event"); + + expect(results).toEqual(["regular", "arrow"]); + }); + + test("should handle function that returns Promise synchronously", () => { + const hookified = new Hookified(); + const results: string[] = []; + + // This is a sync function that returns a Promise + // hookSync will call it, but won't await the Promise + const promiseReturningHandler = () => { + results.push("sync-part"); + return Promise.resolve().then(() => results.push("async-part")); + }; + + hookified.onHook("event", promiseReturningHandler); + hookified.hookSync("event"); + + // Only the sync part executes immediately + expect(results).toEqual(["sync-part"]); + }); + }); });